Compare commits

...

817 Commits

Author SHA1 Message Date
Thomas Tracy
31c116cf58 fix text id 2022-04-07 13:44:25 -04:00
Thomas Tracy
b3f56f8f18 fix test 2022-04-07 13:40:59 -04:00
Thomas Tracy
e74de71fb7 fix: [MICROBA-1769] Fix nonPassing states
When a learner is in the non-passing state for a certificate, we don't
want to give them messaging about certificate status IF the certificate
is not released yet for the course.

Before this change, learners who were were in the nonpassing state would
be informed before finishing the course or before the certificates were
released. After this change, learners who are nonpassing will be in a "not passing not
available" state (similar to "earned but not available" state) until the
course issues its certificate.
2022-04-07 13:33:34 -04:00
Muhammad Soban Javed
af837fcac8 fix: run npm i with npm 8 to update lock file version (#915) 2022-04-06 16:41:04 +05:00
Renovate Bot
e328e3d597 chore(deps): update dependency @testing-library/jest-dom to v5.16.4 2022-04-05 18:39:33 +00:00
Renovate Bot
559160213d fix(deps): update dependency @popperjs/core to v2.11.5 2022-04-05 14:53:55 +00:00
Renovate Bot
878a4616f3 fix(deps): update dependency react-redux to v7.2.8 2022-04-04 16:02:20 +00:00
Renovate Bot
3028d79597 fix(deps): update dependency @edx/frontend-component-header to v2.4.6 2022-04-04 15:44:31 +00:00
alangsto
aa0de7663c chore: upgrade special exams lib version (#906) 2022-04-04 06:41:51 -07:00
edx-semantic-release
acd91a1c31 chore(i18n): update translations 2022-04-04 07:10:20 -04:00
Usama Sadiq
b32817b3dd build: update transifex pull translations command (#903) 2022-04-04 16:04:32 +05:00
Thomas Tracy
8b32e5892f chore: [MICROBA-1780] missing copy edit (#902)
We missed a copy edit from the previous PR made for this ticket. Also
changing the id to something more relevant to the message.
2022-04-01 14:28:58 -04:00
Thomas Tracy
76cf85f3d7 chore: [MICROBA-1780] Copy edits (#901)
A partner was not happy with messaging for a course whose
students were in the "earned-not-available" state. This aims to make the
messaging more clear.
2022-04-01 12:43:05 -04:00
Renovate Bot
7d86c501a7 fix(deps): update dependency react-redux to v7.2.7 2022-03-31 22:18:26 +00:00
Renovate Bot
eeee32c100 fix(deps): update dependency @reduxjs/toolkit to v1.8.1 2022-03-31 22:02:34 +00:00
Renovate Bot
95d88a054e fix(deps): update dependency @edx/frontend-platform to v1.15.6 2022-03-31 21:43:23 +00:00
edX requirements bot
550b15a16c fix: transifex migration to new client 2022-03-31 13:33:29 -04:00
Renovate Bot
715393d6ad chore(deps): update dependency @edx/reactifex to v1.1.0 2022-03-31 11:34:28 -04:00
Renovate Bot
19b4241020 fix(deps): update dependency @edx/paragon to v19.13.6 2022-03-31 09:34:02 -04:00
Renovate Bot
09bd5bd748 chore(deps): update dependency ansi-regex to 3.0.1 [security] 2022-03-31 09:32:44 -04:00
Renovate Bot
89771cb56b chore(deps): update dependency @edx/frontend-build to v9.1.4 2022-03-31 02:23:12 +00:00
Michael Terry
3353ee2f9d fix(deps): update dependency @edx/paragon to v19.13.1 2022-03-28 11:30:08 -04:00
Renovate Bot
7c1821382c fix(deps): update dependency @reduxjs/toolkit to v1.8.0 2022-03-28 11:12:22 -04:00
Ghassan Maslamani
b444d677b7 fix: import i18n messages from header for learning to consume
It just makes the learning consume the i18n of the header, as
  otherwise the learning would show the deafult messages of the
  header.
2022-03-28 10:55:44 -04:00
dependabot[bot]
7178f28838 chore(deps): bump minimist from 1.2.5 to 1.2.6
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 10:44:40 -04:00
Renovate Bot
b07f22193c chore(deps): update dependency ansi-regex to 4.1.1 [security] 2022-03-28 10:43:42 -04:00
Renovate Bot
c6eba42120 chore(deps): update dependency @testing-library/jest-dom to v5.16.3 2022-03-28 09:27:16 -04:00
Michael Terry
7bb2266790 fix: stop stripping most inaccessible sequences from navigation
I had previously made a "fix" to strip all inaccessible sequences
from the learning sequence outline hierarachy, as a way to filter
out unreleased sequences. See commit d1f19a9.

But that was too big a hammer and stripped a lot of released-but-
inaccessible sequences too (e.g. prerequisites).

So now, we adopt a more nuanced approach and explicitly just filter
out sequences that are both inaccessible AND unreleased.

AA-1219
2022-03-28 09:16:05 -04:00
edX Transifex Bot
0a70f9b64e chore(i18n): update translations 2022-03-27 17:08:29 -04:00
Renovate Bot
cfe4432c6b fix(deps): update dependency @edx/frontend-component-footer to v10.2.2 2022-03-24 13:47:25 +00:00
Chris Deery
f7219b4f5d fix: [AA-1219] Crash when locked content rendered without unit ID (#878)
Allows SequenceNavigation to complete rendering even if unitId is not provided.
2022-03-22 17:35:45 -04:00
Renovate Bot
14a19b2794 fix(deps): update dependency @edx/frontend-platform to v1.15.5 2022-03-21 20:21:41 +00:00
edX Transifex Bot
8a9767cdd3 chore(i18n): update translations 2022-03-20 17:08:15 -04:00
Chris Deery
3cba1bbac4 fix: [AA-1207] Remove redundant API fields (#873)
Remove redundant fields from courseware API. These are all found in courseHome: 

- number
- org
- originalUserIsStaff
- isStaff
- verifiedMode
- isMasquerading (virtual field from isStaff and originalUserIsStaff)
2022-03-18 09:20:31 -04:00
Chris Deery
9436770620 fix: improve Guard for Iframe resize (#875)
Fix line that was causing JS errors when it was called in a context without a valid document.
2022-03-17 14:47:19 -04:00
Renovate Bot
d03dd34009 fix(deps): update dependency @fortawesome/react-fontawesome to v0.1.18 2022-03-16 20:57:50 +00:00
Renovate Bot
9cdacde4dc chore(deps): update dependency @pact-foundation/pact to v9.17.3 2022-03-16 15:54:09 +00:00
Renovate Bot
a22ac3a776 fix(deps): update dependency @edx/frontend-platform to v1.15.3 2022-03-14 21:29:02 +00:00
Michael Terry
7e19af44da fix: trust the course grade the LMS gives us for progress page
Rather than recompute it ourselves. Now that the backend is fixed
to consider only visible grades for the course grade, we don't need
to try to work around its logic (which is more accurate/consistent
in general).

AA-1217
2022-03-14 13:04:06 -04:00
Dillon Dumesnil
57c3f3080e feat: AA-1205: Enable Entrance Exam support for Learning MFE (#840)
Adds an alert to the courseware if the section is an Entrance Exam. Also
adds a listener to reload the page upon receiving a message from the LMS
indicating the user has now passed the exam.

Commit also contains misc. clean up for i18n messages switching to variable names.
2022-03-14 08:09:15 -07:00
Renovate Bot
385635f5d1 fix(deps): update dependency @popperjs/core to v2.11.4 2022-03-14 11:59:19 +00:00
edX Transifex Bot
a7f763cd2a chore(i18n): update translations 2022-03-13 17:08:02 -04:00
Renovate Bot
c7c9c19771 fix(deps): update dependency @popperjs/core to v2.11.3 2022-03-12 15:29:22 +00:00
julianajlk
1d3a779ef1 feat: Add past expiration messaging for UpgradeNotification (#853)
REV-2500
2022-03-11 10:37:30 -05:00
julianajlk
4f1a50ec24 feat: Add Value Prop past expiration messaging for gated content (#836)
REV-2500
2022-03-11 09:58:44 -05:00
Chris Deery
72d18dc4f9 fix: [AA-1207] unify source of tabs (#861)
Courseware and courseHome both provide tabs to the mfe.
This PR unifies the calls so that tab descriptions are only fetched from courseHome metadata

Remove jest-chain dependencies to make test errors more usable.
2022-03-10 13:29:30 -05:00
Renovate Bot
2197ec0c21 fix(deps): update dependency @edx/paragon to v19.7.0 2022-03-10 09:28:37 -05:00
Renovate Bot
069ac9c234 chore(deps): update dependency @testing-library/react to v12.1.4 2022-03-10 09:00:20 -05:00
Renovate Bot
3edf349969 fix(deps): update dependency @edx/frontend-platform to v1.15.2 2022-03-08 17:41:52 +00:00
Michael Terry
a2516e9fcc fix: avoid a race condition with redux data on course exit page
I would sometimes see a case where we were trying to access
recommendations data before it was defined.
2022-03-07 15:03:03 -05:00
Renovate Bot
554806e9ce chore(deps): update actions/setup-node action to v3 2022-03-07 14:18:38 -05:00
Renovate Bot
ed13128fc4 fix(deps): update dependency prop-types to v15.8.1 2022-03-07 13:40:23 -05:00
Renovate Bot
373a2d88fc fix(deps): update dependency @edx/frontend-component-header to v2.4.5 2022-03-07 13:40:07 -05:00
Renovate Bot
bcd54a4f4b fix(deps): update font awesome 2022-03-07 13:39:44 -05:00
Renovate Bot
c4cb0e5ac2 chore(deps): update actions/checkout action to v3 2022-03-07 13:39:26 -05:00
Renovate Bot
c77d518d04 fix(deps): update dependency core-js to v3.21.1 2022-03-07 13:39:12 -05:00
Renovate Bot
703250c3d2 fix(deps): update dependency @edx/frontend-component-footer to v10.2.1 2022-03-07 16:52:26 +00:00
Michael Terry
35ec314505 chore: drop unused frontend-enterprise-utils dependency 2022-03-07 11:35:50 -05:00
Michael Terry
9fc7951576 fix: downgrade frontend-build to fix our builds
For a still-unknown reason, 9.1.2 breaks our builds in GoCD.
While that investigation continues, let's drop down to a known
working version.
2022-03-07 11:35:50 -05:00
Michael Terry
4ed350c9c6 chore: update testing-library/react to latest 12.x
Also, drop our specific testing-library/dom dependency. We can
get it transitively though testing-library/react, and they are
coupled closely enough that we don't want them out of lockstep
anyway.

Thus, this commit also updates testing-library/dom to 8.x,
because that's what testing-library/react 12.x needs.
2022-03-07 11:35:50 -05:00
Michael Terry
ebed27529c fix: adjust timer test to work again 2022-03-07 11:35:50 -05:00
Michael Terry
24ced5dc63 chore: update testing-library/dom version and clean dev deps
Get on the latest 7.x release and remove some unused dev deps:
- enzyme
- enzyme-adapter-react-17
- glob
2022-03-07 11:35:50 -05:00
Kshitij Sobti
f004d0ab3c feat: Sidebar refactor and add support for discussions sidebar. (#762)
squash!: remove unnecessary styling and migrate to bootstrap and other review feedback
2022-03-07 18:56:05 +05:00
edX Transifex Bot
1bbcc6d052 chore(i18n): update translations 2022-03-06 16:07:49 -05:00
Renovate Bot
3d122e0fb9 chore(deps): update dependency @wojtekmaj/enzyme-adapter-react-17 to v0.6.6 2022-03-04 00:08:49 +00:00
Renovate Bot
685d2d5593 chore(deps): update dependency @pact-foundation/pact to v9.17.2 2022-03-03 21:38:21 +00:00
Michael Terry
97bd45cfa8 chore: update frontend-build, paragon, header, and footer
A collection of edx-owned npm updates. These required an actual
code change of using our own svg rather than directly loading an
svg from paragon, because paragon has its own svgo config that
can potentially conflict with our version of svgo - as it does
when we update frontend-build.

And with the latest versions of frontend-build, we can now use
the latest versions of paragon.

Header and footer updates thrown in for free.
2022-03-03 16:20:05 -05:00
Chris Deery
55dac2696e fix: [AA-1206] resolve access APIs (#838)
* fix: [AA-1206] resolve access APIs

As part of eliminating redundant fields course_access was removed from
 the Courseware metadata API. There are some differences between the
 two APIs - courseware returned an error if the course had flags
 preventing it from being loaded in the MFE, and courseHome had a
 second field, can_load_courseware, that returned a boolean.

 This fix unifies the handling of the access fields to behave consistently.
2022-03-03 16:12:14 -05:00
Michael Terry
4586f8a6ad chore: update frontend-platform to 1.15.1
The bot PRs to do this stalled out, so I'm doing it manually.
2022-03-03 12:38:55 -05:00
Renovate Bot
88bc1f6956 fix(deps): update dependency @popperjs/core to v2.11.2 2022-03-03 12:08:06 -05:00
Jawayria
4f2f17beb3 fix: update workflow 2022-03-03 11:27:11 -05:00
edX requirements bot
8114750796 build: Added support for node v16 2022-03-03 11:27:11 -05:00
Michael Terry
7b945a9fce fix: guard access to a react ref before using it
This was causing some errors ever since we started using the ref
in an event handler - I'm not entirely sure why the ref stops
being valid, unless it's a lifecycle thing. But anyway, this
seems to stop the error in testing.
2022-03-03 11:09:49 -05:00
Michael Terry
48aad3951a fix: resize course handout iframe as contents change
This can happen if a handout has e.g. expanding list elements.

AA-1215
2022-03-01 13:05:35 -05:00
Michael Terry
dcf8da2279 chore: update transifex scripts to api v3
Transifex is sunsetting anything below v3 later this year. Let's
get ahead of our scripts unexpectedly failing.

- Switch 'reactifex' dev dep to our maintained fork at
  '@edx/reactifex' (it has an older released version number,
  but it's still maintained unlike the original)
- Update Makefile commands for new v3 transifex API
2022-02-28 12:49:35 -05:00
Michael Terry
d8e1124a4c chore: update to paragon 19.3.0
No change on our end, this is just to get us as up-to-date as
possible.

This is the latest version of paragon we probably want to use as
long as we still claim to support IE11 via browserlist. We start
getting warnings about grid-auto-rows not being supported in IE11
beginning in paragon 19.4.0 with its new SelectableBox component.

To update our browerlist to drop IE11, we'll need to update our
node version. Which is in progress elsewhere. But for this series
of commits, I've gotten as far as I can / want to with paragon.
2022-02-28 11:19:19 -05:00
Michael Terry
e9f0a658d6 chore: update to paragon 19.1.0
Replace our custom Tour component with the drop-in ProductTour
replacement in paragon.
2022-02-28 11:19:19 -05:00
Michael Terry
7049445969 chore: update to paragon 19.0.0
Adapt to the big <Card> redesign.

Also, as part of 19.0.0, the shadow variables in paragon got
marked as !default, letting the theme override them. This would
normally be fine and good. But the edx.org theme has a dramatically
larger shadow set. And a lot of our cards and card-like components
were designed with a smaller shadow in mind. So I added a new
raised-card custom css class to keep as many cards looking the same.

Notably, I cannot fix the data tables on the Progress tab. So those
might have a larger shadow with this change, but that's unavoidable
and presumably the intent of the edx.org theme authors.
2022-02-28 11:19:19 -05:00
Michael Terry
f17a635e9d chore: update to paragon 17.1.2
Adapt to secret <Spinner> API breakage (they stopped rendering
children, instead requiring a screenReaderText prop).
2022-02-28 11:19:19 -05:00
Michael Terry
cc8ee33dcd chore: update to paragon 17.0.0
- Drop our custom breakpoints (identical to paragon's)
- Drop our custom useWindowSize (and adapt to paragon's version
  not providing a size initially at component mount)
- Drop our dependency on react-responsive
- Drop our dependency on react-break
2022-02-28 11:19:19 -05:00
Ghassan Maslamani
c25ec8f1ae feat: add descriptions for i18n messages (#815)
It fixes: openedx/frontend-wg/issues/74
2022-02-28 09:10:57 -05:00
Sarina Canelake
8325851813 build: add DEPR workflow automation 2022-02-24 16:05:03 -05:00
edX Transifex Bot
a66d2cf524 chore(i18n): update translations 2022-02-20 16:07:24 -05:00
renovate[bot]
628ede3ccc chore(deps): update dependency follow-redirects to 1.14.8 [security] (#800)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-02-17 16:33:23 -05:00
Michael Terry
c0c51a3028 fix: remove custom newrelic snippet in favor of new frontend-build one (#832) 2022-02-17 16:12:59 -05:00
Simon Chen
947e5e3cb2 fix: deny proctored exam access to audit and honor enrollment tracks (#831)
We need to allow both Timed exams and non-exam types content to be rendered

Co-authored-by: Simon Chen <schen@edX-C02FW0GUML85.local>
2022-02-17 16:10:32 -05:00
renovate[bot]
93baa10141 chore(deps): update dependency node-fetch to 2.6.7 [security] (#821)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-02-17 15:49:19 -05:00
renovate[bot]
c02bf1eeed chore(deps): update dependency @testing-library/jest-dom to v5.16.2 (#760)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-02-17 15:47:13 -05:00
renovate[bot]
b4c90ab506 chore(deps): update dependency jest to v27.5.1 (#756)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-02-17 15:46:24 -05:00
renovate[bot]
e20bed64fb fix(deps): update dependency @edx/paragon to v16.24.0 (#748)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-02-17 15:45:35 -05:00
Dillon Dumesnil
8285d42b7e fix: AA-1194: Update the breadcrumb link (#829)
Before, for sections, it would link you to the chapter which would
cause a JS error since the sequence endpoint expects sequentials.
This updates to now link to the first subsection within a section when
you hit the section breadcrumb
2022-02-17 12:45:13 -08:00
Simon Chen
74484b7847 fix: revert "fix: deny proctored exam access to audit and honor enrollment tracks (#828)" (#830)
This reverts commit 45d5141769.

Co-authored-by: Simon Chen <schen@edX-C02FW0GUML85.local>
2022-02-17 14:58:21 -05:00
Simon Chen
45d5141769 fix: deny proctored exam access to audit and honor enrollment tracks (#828)
Co-authored-by: Simon Chen <schen@edx-c02fw0guml85.lan>
2022-02-17 14:11:51 -05:00
Michael Terry
3c52eb2e8d feat: stop calling course blocks rest API and assume LS exists (#803)
- Assume that Learning Sequences is available (waffle has been
  removed)
- Stop calling course blocks API, which provided mostly duplicated
  information now.
- Refactor a bit to avoid needing to globally know which units
  exist in sequences. That is now provided just-in-time for only
  the current sequence.
- Add /first and /last URLs that you can use instead of unit IDs
  in URL paths, in service of the above point.

AA-1040
AA-1153
2022-02-17 14:10:24 -05:00
Ghassan Maslamani
616027df86 fix: rewrite calcualtor tips to reflect the supported features (#825)
Fixes #820
  - Removing constants that are no longer supported
  - Removing suffixes that are no longer supported
2022-02-17 11:24:00 -05:00
Kyle McCormick
93790464f8 build: move pact dependency to devDependencies (#823)
Pact is a testing library. It is not used in to run the
Learning MFE application. Therefore, it belongs in the
devDependencies section of package.json, not the
dependencies section.
2022-02-16 09:05:25 -05:00
Chris Deery
c2cb5744a1 fix: [AA-1195] update dependencies for security warnings (#826)
fix: [AA-1195] fix dependency security alerts

Update frontend-build dependency to 9.0.6

Update es-check to 6.2.1
2022-02-15 13:02:19 -05:00
dependabot[bot]
5d62cb2f46 chore(deps): bump nanoid from 3.1.30 to 3.2.0 (#809)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.1.30 to 3.2.0.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.1.30...3.2.0)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-15 10:43:35 -05:00
dependabot[bot]
0f11fd6245 chore(deps): bump node-fetch from 2.6.1 to 2.6.7 (#817)
Bumps [node-fetch](https://github.com/node-fetch/node-fetch) from 2.6.1 to 2.6.7.
- [Release notes](https://github.com/node-fetch/node-fetch/releases)
- [Commits](https://github.com/node-fetch/node-fetch/compare/v2.6.1...v2.6.7)

---
updated-dependencies:
- dependency-name: node-fetch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-15 10:41:53 -05:00
dependabot[bot]
2d6e4063ed chore(deps): bump follow-redirects from 1.14.1 to 1.14.8 (#824)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.1 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.1...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-15 10:38:18 -05:00
Bianca Severino
7b6f5ccf86 chore: bump frontend-lib-special-exams to 1.15.3 (#822) 2022-02-14 09:57:08 -05:00
Bianca Severino
61f0ce2023 chore: bump frontend-lib-special-exams to 1.15.2 (#814) 2022-02-08 11:11:02 -05:00
Chris Deery
5706adde4d fix: [AA-1018] api cleanup
revert missing slice from Tabpage call
2022-02-07 11:40:35 -05:00
Chris Deery
ec1c3da725 fix: [AA-1018] api cleanup
Fix error when moving from courseHome to courseware.
2022-02-04 11:48:58 -05:00
Chris Deery
64b0c03d30 fix: [AA-1018] api cleanup
couple of minor fixes from reviews
2022-01-31 11:20:17 -05:00
Chris Deery
3b33aacb3d fix: [AA-1018] api cleanup
Back off change to fetchCourse in courseware/thunks.js
2022-01-31 11:20:17 -05:00
Chris Deery
f907c588c9 fix: [AA-1018] api cleanup
refactor - don't need to synchronize courseHomeMeta and coursewareMeta manually
2022-01-31 11:20:17 -05:00
Chris Deery
99cf1f9f06 fix: [AA-1018] api cleanup
refactor - remove 'slice' prom from TabContainer.jsx
2022-01-31 11:20:17 -05:00
Chris Deery
7f016e55aa fix: [AA-1018] api cleanup
Implement review feedback.
Clean up tests
2022-01-31 11:20:17 -05:00
Chris Deery
f0f8027de4 fix: [AA-1018] api cleanup
Remove extraneous logging statement
2022-01-31 11:20:17 -05:00
Chris Deery
fd3d0f9391 fix: [AA-1018] api cleanup
Remove debugging code
2022-01-31 11:20:17 -05:00
Chris Deery
3fe5bb1733 fix: [AA-1018] api refactor
This is the first step toward clearing out the redundant metadata from the
coursewareMetadata and getting it from a common source - the courseHomeMetadata.

remove username from coursewareMetadata
Remove courseAccess from coursewareMetadata.

Fix all unit tests
Modify classes that use metadataModel to use courseHomeMetadata for common data.
metadataModel still exists as a mechanism to distinguish if a component is under
courseware or courseHome, and it will be renamed or removed in a later refactor.
2022-01-31 11:20:05 -05:00
edX Transifex Bot
6db421eade chore(i18n): update translations 2022-01-23 16:06:37 -05:00
Dillon Dumesnil
b9d1bf0624 feat: AA-1138: Adds in Weekly Goal Celebration Modal (#797)
The logic to show the modal is controlled by the backend.
Displays the modal only in courseware the first time the learner
hits their weekly learning goal. After viewing the goal, the
database row is updated to not show the modal again.

Also updates first section celebration to use the StandardModal
component as the Modal component has been deprecated.
2022-01-18 06:11:36 -08:00
edX Transifex Bot
2789c7415b chore(i18n): update translations 2022-01-16 16:06:24 -05:00
Carla Duarte
8484d98e26 fix: RTL bug on progress tab (#804) 2022-01-14 15:09:11 -05:00
Carla Duarte
b346b741d5 fix: removing overflow-x scroll from checkpoint (#802) 2022-01-14 12:18:36 -05:00
Ihor Romaniuk
eedaa9f2e9 fix: add support for legacy theme static for the LmsHtmlFragment (#785)
Enables an alternative configuration to match the legacy styles. No-op by default.
2022-01-14 11:11:05 -05:00
Zachary Hancock
f2f0cb6008 chore: update special exams lib (#798) 2022-01-13 14:51:51 -05:00
Ihor Romaniuk
b61057f2df feat: add rtl support (#783)
* feat: add rtl support for chart on progress tab

* feat: change 'div' to semantic 'main' tag

* fix: revert changing div to main tag
2022-01-12 09:40:58 -05:00
Carla Duarte
2d46bacdc7 fix: update Tour components and product tour behavior (#794) 2022-01-11 13:50:12 -05:00
Chris Deery
4655b344a7 feat: [AA-922] remove deprecated goals feature (#789)
* fix: [AA-922] remove deprecated goals feature

While the new Weekly Learning Goals were being rolled out, the previous goal setting feature still existed behind a waffle flag.
The Weekly Learning Goals now become the one and only learning goal feature. It is managed behind the course_experience.enable_course_goals flag

- Remove original Goals panel and related components

- Remove references to weeklyLearningGoalEnabled Waffle flag
2022-01-10 13:38:57 -05:00
Michael Terry
41207e953e feat: show error page when xblock fails to render (#795)
AA-1175
2022-01-10 13:33:30 -05:00
Matthew Piatetsky
16a6eeab24 fix: add aria radiogroup role to goals widget (#792) 2022-01-07 14:04:53 -05:00
Michael Terry
907892e7bb fix: don't log errors when we ask for sequence metadata for units (#790) 2022-01-04 15:07:48 -05:00
Zachary Hancock
f5d1b1c897 chore: update special exams library (#788) 2022-01-04 12:32:18 -05:00
edX Transifex Bot
5854afa987 chore(i18n): update translations 2022-01-02 16:10:59 -05:00
edX Transifex Bot
2aa2e42595 chore(i18n): update translations 2021-12-26 16:10:47 -05:00
Chris Deery
edf9e58d6d fix: [AA-1076] show grade override notice (#773)
* fix: [AA-1076] show grade override notice

- Progress page indicates if a grade has been overridden
- add unit test
2021-12-21 14:07:44 -05:00
Michael Terry
d344b501ab fix: add back missing message translation string (#784)
It was accidentally removed when we switched to the external
header (frontend-component-header), but the string is still
actually used.
2021-12-21 13:53:11 -05:00
julianajlk
2bf4f2a0b5 feat: Add NotificationTray persistence by course (#772)
REV-2424
2021-12-21 13:50:07 -05:00
Arslan
de49e8b271 fix: Use Link from router to fix path based routing issue 2021-12-21 12:53:37 +00:00
Michael Terry
fb21f88c02 fix: when LS is enabled, don't re-connect units and sequences (#782)
Learning Sequences (LS) don't need to edit unit blocks at all.
It's not their data and the stitching code didn't have all the
safety guards that the course block normalizer does in api.js.

This fixes an issue with degenerate course layouts (like problems
as direct children of sequences) when LS is enabled. It was trying
to stitch units and sequences together but failing to account for
unitIds that aren't actual units.

Which is technically still supported by the platform, though not
possible in Studio. We could try to do something smarter here, but
that's not LS's job - it should just trust that the unit data is
correctly normalized already. That unit loading code will
eventually move to the sequence metadata anyway (ideally) and LS
won't touch units at all.

AA-1162
2021-12-20 16:55:21 -05:00
Michael Terry
1044d2afc6 feat: use learning sequences even when masquerading (#774)
The backend recently grew support for it, so we can use it
directly instead of falling back to course blocks in that case.

AA-1151
2021-12-20 13:19:38 -05:00
Carla Duarte
aaf2856573 fix: round grades on progress tab (#778) 2021-12-20 09:03:30 -05:00
Asad Iqbal
1546c62e7f feat: Removed course header stuff (#715) 2021-12-20 08:57:05 -05:00
edX Transifex Bot
b8875f3cda chore(i18n): update translations 2021-12-19 16:10:41 -05:00
Carla Duarte
febc0cae0b fix: course breadcrumb styling (#776) 2021-12-16 13:54:32 -05:00
Carla Duarte
cc0c3c24d9 fix: remove launch tour from header (#775) 2021-12-16 12:03:40 -05:00
Carla Duarte
2fa4a837b1 feat: new user course home tour (AA-1027) (#750) 2021-12-14 12:53:10 -05:00
Dillon Dumesnil
32e299e13b feat: AA-1121: Add in eventing for Goals (#770) 2021-12-09 09:11:21 -08:00
Renovate Bot
f92d2e2ecd fix(deps): update dependency @edx/frontend-platform to v1.14.3 2021-12-08 23:28:08 +00:00
Bianca Severino
fffc48b41a chore: update frontend-lib-special-exams to 1.14.1 (#765) 2021-12-08 10:22:10 -05:00
Renovate Bot
0cc2dcdbc5 fix(deps): update dependency @edx/frontend-platform to v1.14.2 2021-12-08 13:09:15 +00:00
Dillon Dumesnil
e9ca92a359 fix: AA-1107: Update styling for goal unsubscribe page (#763) 2021-12-07 11:53:42 -08:00
Renovate Bot
439965847a fix(deps): update dependency core-js to v3.19.3 2021-12-06 09:52:29 +00:00
Simon Chen
436c05487a fix: Only dismiss the modal when masquerading as specific learner (#759)
if the user is masquerading as a specific learner, then dismiss the modal and do not post back and save the Honor Code signature

Co-authored-by: Simon Chen <schen@edX-C02FW0GUML85.local>
2021-12-02 10:08:49 -05:00
Michael Terry
fba300bc5c fix: avoid trying to open a unit as a sequence (#752)
Make sure to always include the sequence ID when changing the URL
from the jump nav dropdown. We got the correct place eventually
anyway, but this avoids some API requests that we know will fail.

AA-1111
2021-12-01 12:28:10 -08:00
julianajlk
8c43de9fc0 feat: Update notification feature to be course specific (#742)
REV-2360
2021-11-30 09:25:14 -05:00
Phillip Shiu
c2b46d50a8 refactor: [REV-2388] remove "non-profit" MM-P2P language (#758) 2021-11-30 09:03:58 -05:00
Renovate Bot
e2ce54dea8 fix(deps): update dependency core-js to v3.19.2 2021-11-29 21:10:12 +00:00
Renovate Bot
af45d899e3 fix(deps): update dependency reselect to v4.1.5 2021-11-25 00:05:33 +00:00
Renovate Bot
15a4ea42b2 chore(deps): update dependency @testing-library/jest-dom to v5.15.1 2021-11-23 16:12:04 +00:00
Renovate Bot
555dddf8de fix(deps): update dependency @edx/frontend-platform to v1.14.1 2021-11-22 13:32:11 +00:00
renovate[bot]
b03e0fd904 chore(deps): update dependency es-check to v6.1.1 (#725)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-19 09:39:52 -05:00
renovate[bot]
a0c2e86a95 fix(deps): update dependency reselect to v4.1.4 (#709)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-19 09:08:15 -05:00
renovate[bot]
39682badef chore(deps): pin dependencies (#735)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-19 09:04:27 -05:00
renovate[bot]
45afc3fbee fix(deps): update dependency @pact-foundation/pact to v9.17.0 (#747)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-19 09:03:56 -05:00
renovate[bot]
15d20dd693 fix(deps): update dependency @edx/paragon to v16.18.0 (#692)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-19 09:03:27 -05:00
renovate[bot]
2d77ad7125 fix(deps): update dependency core-js to v3.19.1 (#705)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-18 16:52:43 -05:00
renovate[bot]
33df4d2b7f chore(deps): update dependency @testing-library/user-event to v13.5.0 (#690)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-18 16:51:25 -05:00
renovate[bot]
09b16976fd chore(deps): update dependency jest to v27.3.1 (#691)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-18 16:50:35 -05:00
renovate[bot]
1b430f99fe chore(deps): update dependency @edx/frontend-build to v8.1.6 (#707)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-18 16:49:42 -05:00
renovate[bot]
982f849f41 fix(deps): update dependency @edx/frontend-platform to v1.14.0 (#713)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-18 16:46:51 -05:00
renovate[bot]
6ec3a4cb5a chore(deps): update dependency @testing-library/jest-dom to v5.15.0 (#722)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-18 16:46:17 -05:00
renovate[bot]
4d29b202b1 chore(deps): update dependency ansi-regex to 5.0.1 [security] (#736)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-18 16:45:15 -05:00
renovate[bot]
99ee1da598 chore(deps): update dependency path-parse to 1.0.7 [security] (#737)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-18 16:44:53 -05:00
renovate[bot]
b896a64853 chore(deps): update dependency tmpl to 1.0.5 [security] (#738)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-18 16:44:10 -05:00
edX Transifex Bot
94ab6d016e chore(i18n): update translations 2021-11-15 02:09:44 +05:00
Michael Terry
41006f5cbf feat: bump New Relic script to v1211 (#743)
From v1209. No notable changes, but just getting up to date.
2021-11-09 13:05:25 -05:00
Chris Deery
9c2c1427e1 fix: [AA-1087] defer goals widget for proctoring (#727)
fix: [AA-1087] defer goals widget for proctoring

- pause showing goals widget while proctor info panel awaits callback.

- Implement tests to check that goals show up when
  proctoring does not

- small refactoring of LearningGoalButton to eliminate test warnings
2021-11-09 10:22:20 -05:00
Bianca Severino
c19f21d257 fix: pass integrity signature flag to exam wrapper (#739) 2021-11-08 13:36:45 -05:00
edX Transifex Bot
1d98de1e0c chore(i18n): update translations 2021-11-08 02:09:30 +05:00
Dillon Dumesnil
64eb268cb0 feat: AA-1020: Add Credit Information to Progress tab (#726)
This is to match what the old Progress tab would show so we
can enable this for all credit courses as well
2021-11-05 11:08:29 -07:00
Carla Duarte
f7428db3c3 feat: product tour components (#695) 2021-11-05 07:57:15 -06:00
Renovate Bot
7986db7027 fix(deps): update dependency @edx/frontend-enterprise-utils to v1.1.1 2021-11-03 09:12:44 +00:00
edX Transifex Bot
7704a8a5d7 chore(i18n): update translations 2021-11-03 00:22:22 +05:00
Phillip Shiu
d88f83311c refactor: [REV-2262] create generic bullets to reuse in all upsell messaging (#723) 2021-11-02 12:42:15 -04:00
Carla Duarte
6f0a69b838 fix: update goal styling (#721) 2021-11-02 09:22:43 -06:00
Chris Deery
7ed1be1960 fix: [AA-1044] add missing h2 for screenreaders (#720)
* fix: [AA-1044] add missing h2 for screenreaders

- Add placeholder h2 tag with message indicating reserve for future use
- internationalize placeholder text
2021-11-01 13:31:19 -04:00
alangsto
663559f8c7 chore: update special exams lib (#718) 2021-11-01 10:24:59 -04:00
edX Transifex Bot
4a56673377 chore(i18n): update translations 2021-11-01 02:09:48 +05:00
Michael Terry
d1f19a9dc4 fix: skip inaccessible learning sequence data (#716)
When normalizing learning sequences, skip inaccessible sequences
and also skip sections with only inaccessible sequences.

This both imitates the legacy course block behavior and also
avoids a failure when merging course block data with LS data
when they disagree about which sequences exist.
2021-10-29 10:53:50 -04:00
Renovate Bot
8a3722a723 fix(deps): update dependency redux to v4.1.2 2021-10-28 10:36:03 +00:00
Chris Deery
4abf6ebdce fix: [AA-1078] weekly learning goals a11y nav (#711)
- Add radio role and aria-checked to button to enable screen reader use
2021-10-27 16:55:41 -04:00
Chris Deery
d1013802ba fix: [AA-1078] a11y and styling for weekly goals (#710)
* fix: [AA-1078] weekly learning goals fine tune

- Fix a11y issues with buttons
- Replace media query with algorithmic approach for determining button layout
- Misc styling fixes
2021-10-27 10:48:14 -04:00
Renovate Bot
581e8c4769 fix(deps): update dependency react-redux to v7.2.6 2021-10-25 21:16:21 +00:00
Michael Terry
ea5c7f516a fix: Revert "fix: disable the call to ecommerce for now (#701)" (#706)
This reverts commit f93519f675.

The original fix may have been working after all and a bogus
test by me using a staff account made me think the discount was
not being offered. Reverting the emergency fix and going to test
the original fix again.
2021-10-25 10:24:23 -04:00
edX Transifex Bot
ce7cef0c6b chore(i18n): update translations 2021-10-25 02:09:35 +05:00
Renovate Bot
45a823e6c7 fix(deps): update dependency @pact-foundation/pact to v9.16.5 2021-10-24 18:08:04 +00:00
David Joy
0b8cf06c29 fix: set modal-lti max-width to be important (#702) 2021-10-21 17:51:27 -04:00
Michael Terry
f93519f675 fix: disable the call to ecommerce for now (#701)
It doesn't seem to be working - instead never calling ecommerce and
always showing the normal non-discount version of the streak modal.

Going to investigate later, but this is just a quick shutoff for
now.
2021-10-21 16:46:10 -04:00
Michael Terry
c39b3ae4c5 fix: don't show 3-day streak discount if it won't provide a discount (#658)
Also, this will pull the actual discount percent from ecommerce,
instead of hardcoding it.

AA-1012
2021-10-21 12:50:14 -04:00
Renovate Bot
c3ea12225d chore(deps): update dependency husky to v7.0.4 2021-10-21 05:26:12 +00:00
Renovate Bot
f914d83510 chore(deps): update dependency @edx/frontend-build to v8.0.6 2021-10-20 16:13:31 +00:00
Matthew Piatetsky
67ea30a45a fix: ensure we don't update goals while users are masquerading (#697) 2021-10-20 10:20:01 -04:00
Phillip Shiu
eabbb440f0 Revert "refactor: create generic bullets to reuse in all upsell messaging (#689)" (#696)
This reverts commit 8735f219e9.
2021-10-20 09:47:39 -04:00
Phillip Shiu
8735f219e9 refactor: create generic bullets to reuse in all upsell messaging (#689)
REV-2262
2021-10-20 07:49:05 -04:00
Chris Deery
c2414ce1ba fix: [AA-906] WeeklyLearningGoals (#694)
Fix incorrect message for returning students
2021-10-19 15:30:04 -04:00
Michael Terry
e6fee7b5b9 fix: catch and redirect URLs with spaces in them (#693)
In an attempt to debug some odd LMS errors (which would happen if
you loaded an MFE page with spaces instead of plus signs), this
commit notices page requests with spaces in the course key and
switches it out for plus signs, logging the incident.
2021-10-19 11:39:06 -04:00
Chris Deery
d8f3c7441e feat: [AA-906] UI for WeeklyLearningGoals (#664)
* feat: [AA-906] UI for WeeklyLearningGoals

Add component to OutlineTab for selecting Weekly Learning Goals
Move start button to before course outline, and put in card with Call to action.
Unit tests
Implement temporary a11y feedback
add react-responsive as a dependency

Everything except for the start/resume button move is behind a waffle flag: course_goals.number_of_days_goals
2021-10-19 10:37:22 -04:00
Renovate Bot
765bf2089c fix(deps): update dependency @fortawesome/react-fontawesome to v0.1.16 2021-10-19 04:14:24 +00:00
Renovate Bot
10fce146fd chore(deps): update dependency @edx/frontend-build to v8.0.5 2021-10-19 04:00:57 +00:00
edX Transifex Bot
b274cb5137 chore(i18n): update translations 2021-10-18 02:10:27 +05:00
Albert (AJ) St. Aubin
a6e539dad2 feat: update the text for unearned certificates
[MICROBA-1536]
2021-10-15 15:38:14 -04:00
renovate[bot]
83fa3f78bc fix(deps): update dependency @edx/paragon to v16.14.9 (#683)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-15 09:08:25 -04:00
renovate[bot]
1e4f3ec151 chore(deps): update dependency @testing-library/user-event to v13.4.1 (#684)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-15 09:08:08 -04:00
renovate[bot]
1ac806b7dd fix(deps): update dependency js-cookie to v3 (#596)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: Michael Terry <mterry@edx.org>
2021-10-14 15:57:03 -04:00
renovate[bot]
1d08618be9 fix(deps): update dependency core-js to v3.18.3 (#627)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-14 15:40:12 -04:00
renovate[bot]
b90a54759c chore(deps): update dependency jest to v27.2.5 (#619)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-14 15:29:41 -04:00
renovate[bot]
a1ef37ca0b fix(deps): update dependency react-router-dom to v5.3.0 (#629)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-14 15:27:39 -04:00
renovate[bot]
d178913e4b chore(deps): update dependency glob to v7.2.0 (#649)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-14 15:26:30 -04:00
renovate[bot]
9f2ce9d152 chore(deps): update dependency @testing-library/user-event to v13.3.0 (#679)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-14 15:25:22 -04:00
renovate[bot]
d6722ca271 fix(deps): update dependency @edx/paragon to v16.14.7 (#644)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-14 15:21:27 -04:00
renovate[bot]
aa2004434e fix(deps): update dependency @edx/frontend-enterprise-utils to v1.1.0 (#682)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-14 15:18:56 -04:00
Renovate Bot
921f3eef06 fix(deps): update dependency @pact-foundation/pact to v9.16.4 2021-10-11 00:04:09 +00:00
edX Transifex Bot
8337fc79be chore(i18n): update translations 2021-10-11 02:10:22 +05:00
connorhaugh
2230173da8 feat: rollout jump nav to course staff (#672)
As per https://openedx.atlassian.net/browse/TNL-7107 we need to rollout jumpnav to course staff.
2021-10-08 09:33:15 -04:00
Thomas Tracy
09072f318b fix: Fix redirect loop (#676)
MICROBA-1523
2021-10-07 16:35:08 -04:00
Michael Terry
1f7cb2cc28 fix: move NewRelic testing script even higher (#675)
Per their docs, this really should be the first script. And we were
hitting some cloudflare scripts being inserted ahead of us. This
might fix that.
2021-10-07 14:44:32 -04:00
Ned Batchelder
c6ad7c51d3 build: use the organization commitlint check 2021-10-07 13:51:59 -04:00
Andrew Shultz
20d4de09d7 fix: upgrade the frontend-lib-special-exams library to v1.13.3 (#674)
prevents confusing exam errors in non-exam situations

MST-905
2021-10-07 11:35:18 -04:00
Matthew Piatetsky
5aa857f1de fix: Unsubscribe audit users from goal remindners on the course exit page (#671)
This reverts commit 20390d1e33.
2021-10-07 09:14:48 -04:00
Thomas Tracy
3fcc0d87c9 [feat] Add notices redirect to learning MFE (#667)
* [feat] MICROBA-1523 Add notices redirect to learning MFE

To support the notices plugin on platform, this adds a redirect to the
course home page. If a user lands on that page and has not acknowledged
a notice, the user will be redirected to notice instead of the course
home.
2021-10-06 14:25:05 -04:00
Bianca Severino
0bc7faaa56 fix: prevent integrity signature creation while masquerading (#665) 2021-10-06 11:26:04 -04:00
connorhaugh
0889b17e85 fix: course home button error. (#669)
In order to finish off TNL-7107 I needed to meet the acceptance criteria: When learners or educators select a section dropdown item they are taken to the first subsection within that section that is not completed by default. If all subsections are completed they should be taken to the first(subsection) in that section.

This reimagining of Jumpnav does that by lazy loading in the menuItem's destinations and routing the user using React-Router.
2021-10-06 11:14:24 -04:00
connorhaugh
f57f39a787 Revert "Feat lazy load jump nav destinations (#663)" (#670)
This reverts commit dd6a499cfc.
2021-10-05 16:55:58 -04:00
Matthew Piatetsky
20390d1e33 Revert "feat: Unsubscribe audit users from goal remindners on the course exit page (#660)" (#668)
This reverts commit 55b3396acd.
2021-10-05 14:26:05 -04:00
Matthew Piatetsky
55b3396acd feat: Unsubscribe audit users from goal remindners on the course exit page (#660) 2021-10-05 11:18:36 -04:00
Renovate Bot
dae0c8931c fix(deps): update dependency @reduxjs/toolkit to v1.6.2 2021-10-05 03:55:38 +00:00
connorhaugh
dd6a499cfc Feat lazy load jump nav destinations (#663)
In order to finish off TNL-7107 I needed to meet the acceptance criteria: When learners or educators select a section dropdown item they are taken to the first subsection within that section that is not completed by default. If all subsections are completed they should be taken to the first(subsection) in that section.

This reimagining of Jumpnav does that by lazy loading in the menuItem's destinations and routing the user using React-Router.
2021-10-04 08:58:37 -04:00
Renovate Bot
0c006e28de fix(deps): update dependency @pact-foundation/pact to v9.16.3 2021-10-01 12:33:15 +00:00
Renovate Bot
bdba0d2c3c fix(deps): update dependency @pact-foundation/pact to v9.16.2 2021-10-01 02:58:12 +00:00
julianajlk
965a299f6c fix: UpgradeNotification bullet point padding override (#659) 2021-09-29 13:08:53 -04:00
julianajlk
7cfeaab330 fix: Value Prop miscellaneous styling fixes (#657)
REV-2188
2021-09-29 10:15:57 -04:00
edX Transifex Bot
76e3173fc4 chore(i18n): update translations 2021-09-28 21:11:36 +05:00
connorhaugh
e1d3b91dca fix: make breadcrumbs limiting accessible to audit (#655) 2021-09-28 09:23:42 -04:00
julianajlk
0592c40496 Fix lockpaywall container display issue on mobile (#654)
REV-2357
2021-09-27 09:24:49 -04:00
Michael Terry
175a40d9fa fix: correct some escaping on the new relic agent string (#653) 2021-09-24 10:53:02 -04:00
Michael Terry
192a58ab51 fix: hardcode the newrelic agent, to load it earlier (#652)
This is a test, before making a more proper fix in frontend-build.
But I'd like to confirm this fixes some issues we've seen with
newrelic metrics.

AA-1015
2021-09-24 10:09:17 -04:00
Simon Chen
7215db6682 fix: Upgrade the frontend-lib-special-exams library to v1.13.2 (#650)
The special exam timer synced with backend to show accurate count down timer

Co-authored-by: Simon Chen <schen@edx-c02fw0guml85.lan>
2021-09-23 11:13:32 -04:00
Bianca Severino
cb29902152 fix: remove special exam and proctoring flags (#648) 2021-09-22 09:11:55 -04:00
connorhaugh
2932d98976 feat: breadcrumb rolloutout flag + analytics (#647)
As an addendum to https://openedx.atlassian.net/browse/TNL-7107, we want to hide rollout behind a frontend feature flag added in https://github.com/edx/edx-internal/pull/5489. We also want to report these events to the events api with name `edx.ui.lms.jump_nav.selected`. Doummentation to add this event is listed at the following PR: https://github.com/edx/edx-documentation/pull/1982
2021-09-21 15:39:52 -04:00
connorhaugh
8c0e98ad4f feat: Breadcrumb Jump Navigation STAGE ONLY (#641)
Enable faster movement through the course content for learners and course instructors familiar with their course structure using jump navigation selectors in dropdown menus that augment our existing breadcrumbs in the learner sequence experience. When learners/instructors click on sections or subsections these menus are revealed and can be selected to jump to this part of the course.

Implemented using paragon's Selectmenu component, and data from the learning_sequences API.

Note: as the L_S api does not yet have completion data, we are holding off on accepting the completion ACs.

Smoke testing and QA testing will be required, as this feature is prominent in the learner experience. 

The feature is presently only rolled out on stage, but will FF to roll out to instructors on test soon.
2021-09-17 15:39:06 -04:00
Renovate Bot
8b9dfd2f08 fix(deps): update dependency @edx/frontend-platform to v1.12.7 2021-09-17 16:50:13 +00:00
Zainab Amir
e0a81d6cc9 feat: add learner type to course homepage (#643) 2021-09-17 11:19:25 +05:00
Renovate Bot
9cdaa64f64 fix(deps): update dependency @edx/paragon to v16.13.3 2021-09-14 22:30:51 +00:00
Michael Terry
54d96cc162 fix: stop logging course-blocks 403 responses as errors (#637)
They are benign and normal for logged out users. Instead, log them
as info messages, so we can still track them if we need to.

AA-1011
2021-09-13 15:31:43 -04:00
Renovate Bot
3da1fb6581 fix(deps): update dependency @edx/frontend-platform to v1.12.6 2021-09-13 10:43:43 +00:00
edX Transifex Bot
268e8b0b40 chore(i18n): update translations 2021-09-13 02:10:28 +05:00
Renovate Bot
d93cb70966 fix(deps): update dependency @edx/paragon to v16.13.2 2021-09-10 19:16:41 +00:00
Michael Terry
90d6ea8137 feat: notify the user if a sequence is hidden because of due date (#636)
Normally, these sequences are skipped. But if the user manually
goes to the section, they should be notified why they can't access
it. That can easily happen if they bookmarked the page or something.

AA-1000
2021-09-10 11:13:48 -04:00
David Ormsbee
73302d72cb doc: ADR for "Direction of Courseware APIs"
Describing the removal of calls to Course Blocks API for courseware
rendering, how those responsibilities would be split, and the motivation
for doing so. TNL-7326
2021-09-09 12:50:25 -04:00
renovate[bot]
666d9e6b38 fix(deps): update dependency @edx/paragon to v16.13.1 (#620)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-09-09 12:04:53 -04:00
Carla Duarte
fcc0cceb8d fix: update progress grade table styling (AA-934) (#635) 2021-09-09 10:07:35 -04:00
Carla Duarte
597ecb7b4e fix: update SubsectionTitleCell styling (AA-934) (#633) 2021-09-08 10:48:55 -04:00
Renovate Bot
5ec0fec0ff fix(deps): update dependency @pact-foundation/pact to v9.16.1 2021-09-08 13:45:01 +00:00
Renovate Bot
74c75af34d fix(deps): update dependency @edx/frontend-platform to v1.12.5 2021-09-06 11:28:17 +00:00
edX Transifex Bot
9a1966a034 fix(i18n): update translations 2021-09-06 02:05:39 +05:00
Renovate Bot
1868606ee8 fix(deps): update dependency react-redux to v7.2.5 2021-09-04 20:58:27 +00:00
Carla Duarte
70e2aa0203 feat: add page banner to masquerade (AA-877) (#606) 2021-09-03 09:47:39 -04:00
Michael Roytman
bdfbbc0b75 feat: Display "Onboarding Past Due" message in onboarding button if onboarding is past due (#472)
[MST-746](https://openedx.atlassian.net/browse/MST-746)

The ProctoringInfoPanel displays information in a learner's course outline about the state of the learner's onboarding. It displays a link to navigate the learner to the onboarding exam if it is available. If the onboarding exam is not yet released, it displays information about the release date. This code changes adds an "Onboarding Past Due" message to the link if the onboarding is past due, as determined by a call to the LMS onboarding endpoint.
2021-09-01 16:32:00 -04:00
Chris Deery
fda9ab6bce Feat: [AA-950] Streak discount productization (#623)
- Remove Jira tag from StreakCelebrationCouponEnabled prop
- Remove "experiment" from streak discount vars
- Cleaned up warning in unit test
- Added mock function for closeStreakCelebration
- Set End Date to 2 weeks from current date
- Updated unit tests
- Fixed naming issues
- Added official coupon code
- Cast isStreakCelebrationOpen to boolean

Co-authored-by: cdeery <cdeery@edx.edu>
2021-09-01 12:21:43 -04:00
Zachary Hancock
04cc668e9b chore: update special-exams-lib (#625) 2021-08-31 11:09:52 -04:00
alangsto
c91e5d5f58 chore: update frontend-lib-special-exams version (#624) 2021-08-30 16:44:50 -04:00
Renovate Bot
22ca88c981 fix(deps): update dependency core-js to v3.16.4 2021-08-29 17:06:50 +00:00
Renovate Bot
ffd03cb1de fix(deps): update reactrouter monorepo to v5.2.1 2021-08-28 01:50:24 +00:00
edX Transifex Bot
8cfe4bc099 fix(i18n): update translations 2021-08-27 00:49:52 +05:00
Phillip Shiu
191ef9c7b9 fix: add accent to e in resumé (#616)
Fixes: REV-2214
2021-08-26 13:02:05 -04:00
Renovate Bot
ac0813816f fix(deps): update dependency @edx/paragon to v16.9.1 2021-08-26 13:14:15 +00:00
Diane Kaplan
a614145e6d REV-2297: add NotificationTray red dot functionality, so learner notices new prompt 2021-08-25 13:08:23 -04:00
Renovate Bot
ca9a000fd2 chore(deps): update dependency husky to v7.0.2 2021-08-25 03:12:11 +00:00
Renovate Bot
9e2b2ec541 fix(deps): update dependency core-js to v3.16.3 2021-08-24 21:57:17 +00:00
Michael Terry
8552329739 feat: add a new course goal unsubscribe landing page (#612)
URL format: /goal-unsubscribe/<uuid-token>

This is designed to be used in the new course goals feature, where
emails will be sent to learners and those emails will include a
link to this landing page, as an unsubscribe link.

Also, update calls to the LMS course home API to not include the
/v1/ fragment anymore, as we're moving to an unversioned API.

AA-907
2021-08-24 16:10:04 -04:00
Bianca Severino
90fc5f0024 fix: pass originalUserIsStaff to SequenceExamWrapper (#610)
Pass this value to determine whether the user is staff
masquerading as a learner.
2021-08-24 13:01:41 -04:00
Renovate Bot
d3c44f3984 fix(deps): update dependency @edx/frontend-lib-special-exams to v1.12.1 2021-08-24 15:04:16 +00:00
Awais Ansari
a607fe4574 fix: media query max-width effect on course content pages (#600) 2021-08-24 15:08:30 +05:00
Diane Kaplan
2d5e1caae7 feat: cleanup tracking code for obsolete upsell link (#531)
Co-authored-by: Diane Kaplan <dkaplan@edx.org>
See: https://openedx.atlassian.net/browse/REV-2305
2021-08-23 15:26:08 -04:00
Ali Akbar
5087353e88 test: add pact tests for courseware and course-home (#598) 2021-08-23 20:02:31 +05:00
edX Transifex Bot
2171c28825 fix(i18n): update translations 2021-08-23 02:05:31 +05:00
Renovate Bot
adde6e3470 chore(deps): update dependency @edx/frontend-build to v8.0.4 2021-08-20 19:55:35 +00:00
Robert Raposa
0d015be97e build: git ignore local environment overrides (#580)
Git ignore for .env.private to enable local environment
overrides as detailed by frontend-build. See:
https://github.com/edx/frontend-build#override-default-envdevelopment-environment-variables-with-envprivate
2021-08-20 09:13:35 -04:00
Renovate Bot
dfbdcee163 chore(deps): update dependency @edx/frontend-build to v8.0.3 2021-08-19 23:16:57 +00:00
renovate[bot]
3ad7b9e95d fix(deps): update dependency @edx/frontend-platform to v1.12.4 (#605)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: Michael Terry <mterry@edx.org>
2021-08-19 11:32:23 -04:00
renovate[bot]
aedee4f847 fix(deps): update dependency @edx/paragon to v16.9.0 (#604)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-19 11:03:10 -04:00
renovate[bot]
6878ef9fe1 fix(deps): update dependency @edx/frontend-enterprise-utils to v1 (#602)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-19 11:02:37 -04:00
Renovate Bot
04dc5a26ec chore(deps): update dependency @edx/frontend-build to v8.0.1 2021-08-18 01:54:46 +00:00
Renovate Bot
33348eabbd fix(deps): update dependency core-js to v3.16.2 2021-08-17 17:10:28 +00:00
edX Transifex Bot
c5a383dfdb fix(i18n): update translations 2021-08-16 02:10:23 +05:00
renovate[bot]
1177c6e2e2 chore(deps): update dependency axios-mock-adapter to v1.20.0 (#597)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-13 08:56:56 -04:00
renovate[bot]
d6fdf1512f chore(deps): update dependency es-check to v6 (#595)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 16:10:22 -04:00
renovate[bot]
936885707d chore(deps): update dependency @testing-library/user-event to v13 (#594)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 16:09:28 -04:00
renovate[bot]
71db431b97 chore(deps): update dependency @edx/frontend-build to v8 (#590)
* chore(deps): update dependency @edx/frontend-build to v8

* fix: install the util package, webpack5 no longer polyfills it

Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: Michael Terry <mterry@edx.org>
2021-08-12 14:54:55 -04:00
Adam Stankiewicz
b741525bfc fix: replace frontend-enterprise with frontend-enterprise-utils (#593) 2021-08-12 14:42:27 -04:00
renovate[bot]
0394118608 chore(deps): update codecov/codecov-action action to v2 (#589)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 14:14:48 -04:00
renovate[bot]
c6df8cdbb5 fix(deps): update dependency @reduxjs/toolkit to v1.6.1 (#156)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 13:15:21 -04:00
renovate[bot]
1fbd9d4645 fix(deps): update dependency core-js to v3.16.1 (#269)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 13:02:48 -04:00
renovate[bot]
42a3f6b244 fix(deps): update dependency react-helmet to v6.1.0 (#130)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 12:57:40 -04:00
renovate[bot]
6ca4c99c0d fix(deps): update dependency redux to v4.1.1 (#522)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 12:54:56 -04:00
renovate[bot]
5deac01615 fix(deps): pin dependency @pact-foundation/pact to 9.16.0 (#583)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 12:53:01 -04:00
renovate[bot]
1160353ab9 fix(deps): update dependency @edx/paragon to v16.7.0 (#588)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 12:52:43 -04:00
renovate[bot]
ca8cfda9b9 fix(deps): update dependency truncate-html to v1.0.4 (#587)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 12:45:02 -04:00
renovate[bot]
3d9cb20e33 fix(deps): update dependency react-share to v4.4.0 (#216)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 11:52:20 -04:00
renovate[bot]
37f32fddf2 chore(deps): update dependency husky to v7 (#523)
* chore(deps): update dependency husky to v7

* fix: migrate config from husky4 to husky7

Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: Michael Terry <mterry@edx.org>
2021-08-12 11:52:11 -04:00
Renovate Bot
b5689a7997 fix(deps): update dependency regenerator-runtime to v0.13.9 2021-08-12 15:46:30 +00:00
renovate[bot]
c88ea31c20 fix(deps): update dependency @edx/frontend-component-footer to v10.1.6 (#585)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 11:20:55 -04:00
renovate[bot]
9de77c282d chore(deps): update dependency codecov to v3.8.3 (#584)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 11:20:33 -04:00
renovate[bot]
1b995d2510 fix(deps): update react monorepo to v17 (#255)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 11:20:09 -04:00
renovate[bot]
66300caf30 chore(deps): update dependency @edx/frontend-build to v5.6.14 (#330)
* chore(deps): update dependency @edx/frontend-build to v5.6.14

* fix: avoid circular dependency to keep eslint happy

Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: Michael Terry <mterry@edx.org>
2021-08-12 11:20:02 -04:00
Robert Raposa
29391f7741 build: update frontend-platform to 1.12.3 (#582)
* build: update frontend-platform to 1.12.1

* build: update frontend-platform to 1.12.3
2021-08-12 11:03:39 -04:00
renovate[bot]
15782609c3 chore(deps): update dependency @testing-library/user-event to v12.8.3 (#160)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 10:55:42 -04:00
renovate[bot]
f2fc950678 chore(deps): update actions/setup-node action to v2 (#314)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 10:49:22 -04:00
renovate[bot]
42445d884f fix(deps): update react monorepo to v16.14.0 (#244)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 10:28:30 -04:00
renovate[bot]
a5ea7431fc chore(deps): update dependency rosie to v2.1.0 (#454)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 10:25:19 -04:00
renovate[bot]
df29cd0f9a fix(deps): update font awesome (#154)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 10:24:40 -04:00
renovate[bot]
4898487a82 chore(deps): update dependency @testing-library/jest-dom to v5.14.1 (#158)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 10:19:27 -04:00
renovate[bot]
6588153e4c chore(deps): update dependency jest to v27 (#163)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 10:12:19 -04:00
renovate[bot]
a568c5f2fc chore(deps): update dependency axios-mock-adapter to v1.19.0 (#260)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 10:06:31 -04:00
renovate[bot]
054afc0475 fix(deps): pin dependency lodash.camelcase to 4.3.0 (#520)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 10:05:10 -04:00
renovate[bot]
58e8de2c22 chore(deps): update dependency es-check to v5.2.4 (#361)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 10:03:30 -04:00
renovate[bot]
2b00cecd19 fix(deps): update dependency classnames to v2.3.1 (#518)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-08-12 10:02:31 -04:00
Ali Akbar
58f1634c63 test: add consumer pact tests for courseware (#510) 2021-08-10 14:57:24 +05:00
edX Transifex Bot
2092a5d8d1 fix(i18n): update translations 2021-08-09 02:05:23 +05:00
Michael Terry
1308d1e90b docs: explain the learning MFE specific environment variables (#578)
This adds some links to the docs for general MFE instructions,
plus documents the learning MFE specific ones.

Also fixes the README badge links.
2021-08-06 10:15:06 -04:00
Matt Tuchfarber
3b4dcfefaf fix: ignore grade for allowlist certs (#577)
Allowlist certs were hidden on the progress view because the grade
wasn't passing. This gives "downloadable" certs preference over the
grade.
2021-08-05 16:07:54 -04:00
Zachary Hancock
d4a4cd24ec feat: staff may review exam content without attempt (#576) 2021-08-04 13:50:44 -04:00
Dillon Dumesnil
149ca245fd style: Update subsection title to be greyed out if unavailable (#574)
AA-932
2021-08-03 05:36:32 -07:00
Emma
56ea6d46d4 fix: dfferentiate upsell link eventing between in_course and course_home 2021-08-02 10:40:03 -04:00
Thomas Tracy
d12e93d80a [feat] MB-1192: Add not passing, course ended status to certificate status alert (#548)
[feat] Add not passing, course ended status to certificate status alert on the outline tab
2021-08-02 10:10:08 -04:00
Chris Deery
63c86701de fix: Swap the prev and next icons in rtl (#571)
Right To Left (RTL) languages need to reverse the
direction of the icons in navigation.
This fix corrects the arrows in UnitNavigation.jsx,
which were missed in the previous checkin.

Fixes: AA-891

Co-authored-by: cdeery <cdeery@edx.edu>
2021-08-02 10:01:54 -04:00
edX Transifex Bot
b99910357b fix(i18n): update translations 2021-08-02 02:05:24 +05:00
Michael Terry
b4bedfe3f0 fix: re-enable access error redirects for course home (#570)
These redirects are already in place for the courseware, and will
redirect to the outline page, or the dashboard, or wherever, based
on the type of access error (unenrolled, access expired, survey
needed, etc).

This commit stops the course home tabs from paying attention to the
401 error messages coming from the backend - course_access in the
metadata API handles that now.

This commit also changes our useModel hook to more gracefully handle
not being able to find the model - it is a valid case we want to
allow (still will cause problems if you actually try to use the data,
but will at least provide an object you can inspect).
2021-07-30 14:20:42 -04:00
Brian Mesick
8c41e182a2 feat: Remove upgrade sock from course pages (#556)
REV-2220: The upgrade sock is being removed from the remaining course pages in favor of the new Value Prop work.
2021-07-30 12:55:09 -04:00
David Ormsbee
fae2396977 refactor: Begin transition to Learning Sequences API
For performance and long term simplification reasons, we want to take
the work currently done by the Course Blocks API and split it up between
the Learning Sequences API (course outline) and Sequence Metadata API
(details about the Units in a Sequence). This will also make it easier
to later support different kinds of Sequences, where we might not know
all the details about it at the time we load the course-wide outline
data.

This starts moving over the responsibility for the high level outline
and metadata to Learning Sequences. It requires that the waffle flag
"learning_sequences.use_for_outlines" be active in the LMS. If that flag
is not active, the Learning Sequences API call will return a 403 error,
and this code will fall back to the older behavior.

Some data could not be shifted over yet. Namely:

* Sequence legacy URL is not currently output by the Learning Sequences
  API. This is simple to add, but I don't know if there's any point in
  adding it now that the Courseware MFE is functional for timed exams.
* Unit metadata was not completely shifted over to the Sequence Metadata
  API because doing so would cause blocking requests and would cause a
  noticeable performance regression. This should not be moved over until
  the Sequence Metadata API can be made more performant.
* Effort Estimation currently relies on content introspection of the
  underlying content in a way that the Learning Sequences API does not
  support.

This is the last of a handful of PRs in support of TNL-8330.
2021-07-29 12:04:36 -04:00
Chris Deery
276f2a516a fix: Swap the prev and next icons in rtl (#566)
Right To Left (RTL) languages need to reverse the
direction of the icons in navigation.

Fixes: AA-891

Co-authored-by: cdeery <cdeery@edx.edu>
2021-07-29 11:39:58 -04:00
Michael Terry
01ba277425 fix: adjust syntax of a translation, so transifex will accept it (#568) 2021-07-29 08:56:43 -04:00
Saad Yousaf
64f374855b fix: remove focus state for Navigation tab items. (#567)
Co-authored-by: SaadYousaf <saadyousaf@A006-00314.local>
2021-07-28 21:37:15 +05:00
Carla Duarte
a8348e1568 feat: updating alerts to use Paragon Alert over custom (AA-914) (#557) 2021-07-28 09:39:31 -04:00
Viktor Rusakov
6a3ad1d659 chore: update special exams library version (#564) 2021-07-28 07:22:12 -04:00
Saad Yousaf
2f0933be6e fix: update container-fluid to container-xl class to match legacy experience. (#530)
Co-authored-by: SaadYousaf <saadyousaf@A006-00314.local>
2021-07-28 02:01:26 +05:00
Saad Yousaf
4be3b8a56f fix: update Navigation tab items styling to match design. (#536)
Co-authored-by: SaadYousaf <saadyousaf@A006-00314.local>
2021-07-28 01:28:50 +05:00
Matthew Piatetsky
2075a0b3dd fix: grey out problem scores when a learner does not have access, instead of when a learner does have access (#565) 2021-07-27 11:52:51 -04:00
Michael Terry
52750ef769 fix: temporarily disable course-home denied redirects (#563)
They caused a regression when not logged in or enrolled. Will
hopefully re-enable after developing a fix.
2021-07-27 11:46:38 -04:00
Matthew Piatetsky
e2b00d6684 [AA-933] fix: address issues with progress page fbe exception UI (#561)
* fix: address issues with progress page fbe exception UI

* address comments
2021-07-27 10:59:23 -04:00
Michael Terry
6003865840 fix: remove unused portions of effort estimate experiment (#560)
We are sticking with the sequence version, and abandoning the section
version. This commit also marks the strings for translation, as it
is a real feature now, not just an experiment.

AA-659
2021-07-27 09:46:02 -04:00
Zachary Hancock
30a487ec13 chore: upgrade special-exams library (#562) 2021-07-27 09:26:47 -04:00
Michael Terry
c667e29492 fix: handle course access errors in Course Home side of things too (#558)
The courseware was properly reading the access errors and
redirecting the user as appropriate (like to the dashboard or
whatever).

This requires a backend change to push the error along.
2021-07-26 16:33:59 -04:00
edX Transifex Bot
be4375dd7c fix(i18n): update translations 2021-07-26 02:05:37 +05:00
Dillon Dumesnil
a4d651a77a fix: AA-912: If the URL is not provided, only show display name (#554)
In https://github.com/edx/edx-platform/pull/28233, the logic was updated
to only return a URL if the content was still accessible to the learner.
This handles the case of the URL being null
2021-07-22 07:17:59 -07:00
Diane Kaplan
3703e8d81d feat: add upsell events for progress tab upsell links (#532) 2021-07-22 09:05:16 -04:00
julianajlk
c5e480456a fix: LockPaywall content gating layout issue (#549)
REV-2307
2021-07-21 10:22:05 -04:00
Emma
d57dd66dd2 add edxwelcome code info to unhappy path 2021-07-21 10:19:04 -04:00
Matthew Piatetsky
377f780e85 feat: handle FBE Exception cases on new progress page (#539) 2021-07-20 12:20:53 -04:00
Carla Duarte
057b431818 fix: problem score styling (#546) 2021-07-19 13:47:59 -04:00
edX Transifex Bot
e4a883e335 fix(i18n): update translations 2021-07-19 02:05:25 +05:00
Carla Duarte
984926d97c fix: progress score margin (#545) 2021-07-15 15:28:35 -04:00
Viktor Rusakov
9f81897fd2 feat: update onboarding link on the course home page (#542) 2021-07-15 15:10:17 -04:00
Carla Duarte
270c177a83 feat: add problem scores to progress tab (AA-875) (#538) 2021-07-15 13:47:51 -04:00
Albert (AJ) St. Aubin
915f521976 fix: Corrected status message with incorrect mention of email 2021-07-15 10:27:45 -04:00
Thomas Tracy
903d8d4cfb [feat] MB-1299 Add tracking to cert alert buttons (#541)
* [feat] Add tracking to cert alert buttons
2021-07-14 14:02:53 -04:00
julianajlk
f2f4f5f3a5 Fix bug that was applying the CSS to other active classes (#543) 2021-07-14 12:25:32 -04:00
Diane Kaplan
28d359e715 feat: remove first purchase discount banner from courseware (REV-2132) 2021-07-14 11:44:23 -04:00
julianajlk
d93df0e06f feat: remove value_prop_cookie to show the Notification feature in courseware (#524)
Part 4 of REV-2130
2021-07-14 10:47:32 -04:00
Albert (AJ) St. Aubin
86a4cf9af7 feat: Added the Request Certificate Alert
[MICROBA-678]

When a certificate is in a unexpected state (i.e. notpassing with a
passing grade) this alert will allow the user to attempt to resolve the
issue on their own. It will run the code that checks the certificates
status. It requires that the course is configured to allow users to
Request Certificates though.
2021-07-13 10:52:28 -04:00
Thomas Tracy
e423dddb03 [fix] Add href to dates link on coming soon alert (#535) 2021-07-12 13:33:34 -04:00
Sagirov Evgeniy
f21dad95b5 feat: [BD-26] Timer bar on non-sequence pages (#502)
* feat: Timer bar on non-sequence pages

* chore: Update frontend-lib-special-exams version.

Co-authored-by: Viktor Rusakov <vrusakov66@gmail.com>
Co-authored-by: Igor Degtiarov <igor.degtiarov@raccoongang.com>
2021-07-12 11:12:43 -04:00
Saad Yousaf
9978ddf418 fix: change styling of More button to stay consistent with other navigation items. (#528)
Co-authored-by: SaadYousaf <saadyousaf@A006-00314.local>
2021-07-12 18:15:27 +05:00
edX Transifex Bot
d2a8d870af fix(i18n): update translations 2021-07-12 02:05:26 +05:00
Thomas Tracy
3ef4daecce feat: Add Scheduled content alert
Adds a new alert to the outline page that informs the learner of content
coming soon to the course.
2021-07-08 15:47:12 -04:00
Matthew Piatetsky
d2573a16b1 feat: add user id parameter to progress page (#505) 2021-07-08 12:17:43 -04:00
Albert (AJ) St. Aubin
e7c0ebdfe3 Revert "feat: Add Scheduled content alert"
This reverts commit 83151d291c.
2021-07-07 10:39:15 -04:00
renovate[bot]
1ad2cf73bf fix(deps): update dependency @edx/frontend-lib-special-exams to v1.8.3 (#501)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-07-07 09:26:12 -04:00
edX Transifex Bot
d60d782e5a fix(i18n): update translations 2021-07-07 03:11:53 +05:00
Thomas Tracy
83151d291c feat: Add Scheduled content alert
Adds a new alert to the outline page that informs the learner of content
coming soon to the course.
2021-07-06 16:08:55 -04:00
Viktor Rusakov
ed55920f99 refactor: place all logic regarding special exam redirect into single function (#521) 2021-07-06 13:02:29 -04:00
julianajlk
4f1c8a4671 refactor: rename Value Prop related notification components (#516)
Part 3 of REV-2130
2021-07-06 12:43:49 -04:00
edX Transifex Bot
0878bf9f13 fix(i18n): update translations 2021-07-05 02:09:17 +05:00
Zachary Hancock
21f3875dae chore: update special exams lib (#517) 2021-07-02 13:29:30 -04:00
Carla Duarte
d4dc75c5a0 feat: updating shift dates banners (#512) 2021-07-02 10:11:49 -04:00
julianajlk
8a1151e8c5 feat: add Value Prop upgrade notification to Courseware (#511)
Part 2 of REV-2130
2021-07-02 10:11:41 -04:00
julianajlk
5e925c93da Move UpgradeCard to generic directory to be accessible in courseware (#514)
Part 1 of REV-2130, Value Prop work
2021-07-02 09:29:48 -04:00
Jansen Kantor
43033ddc91 move mmp2p useModel call and use outline model (#509) 2021-06-30 09:07:59 -04:00
Kristin Aoki
cf08fa5eb9 fix: Autoscroll moves element to center of page
This PR fixes the anchor tag's position on the page when autoscrolling
is used. Previously, the scroll would move the element to the center of
the page. Now the scroll moves the element to the top of the page. The
only case where the element will not be at the top of the page is when
the element is too close to the bottom of the page and there is not
enough page remaining to force the element to the top.
2021-06-29 09:05:04 -04:00
Thomas Tracy
37ce01c00a Ttracy/microba 1192 early with info display behavior (#497)
* [feat] Add ID verification Alert to course home

if a user has a verified seat, but is in the unverified certificate
status state, the certificateStatusAlert will now show a message letting
the learner know they need to verify in order to earn a certificate.

This does not remove the message about the verification deadline in the
right sidebar of the course home.
2021-06-28 12:20:06 -04:00
David Ormsbee
9edac2519a fix: remove sequences we shouldn't see by using learning_sequences
Removes sequences we shouldn't see by using the Learning Sequences API
(TNL-8377). Depends on https://github.com/edx/edx-platform/pull/27955

It works by adding a call to the Learning Sequences API and (if that
endpoint is enabled, i.e. returns 200 for this user+course), uses the
results of that endpoint to remove sequences from the Course Blocks API
call. Learning Sequences knows how to do things like bubble up the
content group settings of units to sequences for the case where all
units have the same restrictions and the user would see an empty
sequence.
2021-06-28 11:41:56 -04:00
Bianca Severino
f9fbc1eb49 fix: add missing check for graded units (#508)
This fixes a bug where if the learner needs an integrity signature, but
the unit is not graded, neither the honor code panel nor the unit
content would display.
2021-06-28 10:12:58 -04:00
edX Transifex Bot
5c68c1d554 fix(i18n): update translations 2021-06-28 02:09:03 +05:00
Zachary Hancock
4180a2e7a0 chore: update special exams library (#503) 2021-06-24 13:10:51 -04:00
Brian Mesick
554e63d653 feat: Remove upsell banner on course home (#499)
REV-2233: The upsell banner now duplicates the sidebar banner. Removing it in favor of the new implementation on course home, but keeping the masquerade message that shows instructors when a learner lost access to the course.
2021-06-24 12:46:57 -04:00
Kristin Aoki
c9f299eada fix: Autoscroll on page when using jump_to_id
This PR adds a URL hash check to useEffect. Previously the anchor tags
that use jump_to_id would remain at the top of the page. As a result,
users would have to manually scroll to the target location or just read
the full page. Now when the page has a URL hash, it will send the hash
to the listener in the iframe. Using the message listener, it receives
an object with offset in the event.data and the page will scroll to the
location provided by offset. This change will impact the Learner in the
New Experience view.
2021-06-23 12:57:47 -04:00
Carla Duarte
d1bb46eef3 AA-815: ui and a11y progress tab fixes (#494) 2021-06-23 09:42:12 -04:00
Renovate Bot
492c573f56 fix(deps): update dependency @edx/frontend-component-footer to v10.1.5 2021-06-23 08:03:06 +00:00
Brian Mesick
0c55863a3d Revert "feat: Remove upsell banner on course home (#493)" (#498)
This reverts commit fbd9d858e4.
2021-06-22 10:37:11 -04:00
Brian Mesick
fbd9d858e4 feat: Remove upsell banner on course home (#493)
REV-2233: The upsell banner now duplicates the sidebar banner. Removing it in favor of the new implementation on course home, but keeping the masquerade message that shows instructors when a learner lost access to the course.
2021-06-22 10:01:57 -04:00
Rebecca Graber
7b0429f472 feat: make course recommendations a part of the course celebration (#486) 2021-06-21 13:18:35 -04:00
Brian Mesick
56decd8ed0 feat: Remove course sock from OutlineTab. (#495)
REV-2122: As part of the Value Prop implementation, we are removing the course sock from Course Home
2021-06-21 12:55:11 -04:00
Matthew Piatetsky
3155055276 feat: change color of start/resume course button (#496) 2021-06-21 10:58:03 -04:00
Albert (AJ) St. Aubin
1b84930a84 feature: Added notification and link to get to a users PDF cert.
This feature will allow users with downloadable PDF certificates to see
the certificate status alert and then access their certificate on the
Course Outline page. This should only show once a learner has earned a
certificate and that certificate is available.
2021-06-17 07:33:33 -04:00
Michael Terry
99185a9b8a fix: guard against courseGoals being undefined (#489)
No idea why this would happen honestly - it looks always defined
from the backend, and api.js doesn't transorm it. But we are seeing
JS errors related to it. So trying this as a first pass.

AA-848
2021-06-15 13:13:38 -04:00
Carla Duarte
e9c3a6bc5e fix: update assignment policy logic (#483) 2021-06-14 11:22:57 -04:00
Sagirov Evgeniy
5a30cddd32 feat: Added 'allow_proctoring_opt_out' attribute for SequenceMetadata API. (#485) 2021-06-14 10:44:37 -04:00
Ihor Romaniuk
432cb669f5 feat: add temporary flag for enabling/disabling proctored exams (#464) 2021-06-14 10:42:53 -04:00
Matthew Piatetsky
26e1eb64c5 fix: ensure clicking the bookmarks button doesn't break the unitHasLoaded property (#481) 2021-06-14 09:36:15 -04:00
edX Transifex Bot
30e0e3b8f4 fix(i18n): update translations 2021-06-14 02:08:38 +05:00
Carla Duarte
46e459aaf3 fix: update progress tab assignment policy logic (#482) 2021-06-11 12:05:40 -04:00
Carla Duarte
6949fa8201 fix: updating progress tab to better respect showGrades field (#480) 2021-06-11 09:42:06 -04:00
Albert (AJ) St. Aubin
fab2da4586 feature: Improve the Certificate Alerts in the outline to support new
statuses

[MICROBA-678]

These changes refactor the CertificateAvailableAlert and add new
features to it to support more status alerts for certificates. It
attempts to do so in an iterative manner so that new/updated alerts can
be included over time.
2021-06-10 13:03:12 -04:00
Kristin Aoki
6a402c50ea fix: Scroll page when html anchor tag clicked
This PR adds a listener check to messages. Previously the anchor tags that were set to scroll on the page to another element would open the link outside of the iframe and redirect the parent page. As a result, users would have to have to click the back arrow to navigate back to the course and continue the unit. Now when the listener receives an object with offset in the event.data, the page will scroll to the location provided by offset. The offset is only received when a user clicks on an anchor tag in the unit iframe that focuses on another element on the page. This change will impact the Learner.

Jira issue: TNL-8312
2021-06-09 13:18:46 -04:00
Zachary Hancock
bfeb8c70c0 fix: pin special exams library (#479) 2021-06-09 11:09:05 -04:00
David Ormsbee
cf61c7a747 fix: display Unit titles as <h1> (TNL-8387)
Unit titles were being written to the page as <h2> because the old
courseware experience reserved <h1> for wrapping the header logo link.
We've since determined that this is not a best practice, and the new
courseware MFE in this repo no longer uses a <h1> for that purpose, but
the Unit title was never promoted from <h2> to <h1> until this commit.

Course teams have traditionally been permitted to use <h3>-<h6> in their
content. Making this change does mean that there will now be a gap with
some content, where we skip from <h1> to <h3>. For the short term, we
are NOT recommending course teams use <h2>, until we have a better
chance to evaluate whether that heading should remain reserved for
platform-level use.
2021-06-07 13:49:58 -04:00
Adeel Ehsan
a003059c8f Account activation pop up added: (#474)
VAN-435
2021-06-07 10:33:59 +05:00
Adeel Ehsan
854010ba52 Revert "Account activation pop up added: (#425)" (#473)
This reverts commit 07b82b1d87.
2021-06-05 01:14:52 +05:00
Adeel Ehsan
07b82b1d87 Account activation pop up added: (#425)
VAN-435
2021-06-04 20:13:12 +05:00
Bianca Severino
5c204ad0f9 feat: add honor code component (#465)
This component blocks access to graded units when
the user is required to sign the integrity agreement for
the course. Once signed, it will not appear for the course
again.
2021-06-04 09:06:32 -04:00
Matthew Piatetsky
5bfca28450 feat: update streak discount coupon expiration date (#471) 2021-06-02 17:12:56 -04:00
Carla Duarte
a36da4cd84 AA-807: progress tab eventing (#470) 2021-06-02 14:31:13 -04:00
Diane Kaplan
f08a23ecf9 feat: remove first purchase discount banner from course home (REV-2253) 2021-06-01 09:12:28 -04:00
edX Transifex Bot
3432b0c73b fix(i18n): update translations 2021-05-29 02:00:46 +05:00
edX Transifex Bot
c1c3d5c68f fix(i18n): update translations 2021-05-29 00:03:12 +05:00
julianajlk
2a52534442 fix: gated content banner discount display (#466)
REV-2232
2021-05-28 13:38:49 -04:00
Carla Duarte
519cf27c4e AA-813: fix progress tab related links (#461) 2021-05-26 12:01:18 -04:00
Carla Duarte
9d07f26f13 AA-807: upgrade deadline passed on progress tab (#463) 2021-05-26 12:01:12 -04:00
Albert (AJ) St. Aubin
fdfb60bee8 feat: Updating the messages for certificate availability.
[MICROBA-678]
2021-05-26 07:23:48 -04:00
Dillon Dumesnil
75c9e93241 refactor: Update the defaults for our .env files (#459)
Nulls can provide undesired behavior so we want to switch to empty strings
2021-05-24 09:08:48 -07:00
Vladas Tamoshaitis
a5ba5655b6 feet: [BD-26] Add support for special exams (#435)
* feat: add packages dir to .gitignore

* Investigate exam redirect (#2)

* feat: remove exam redirect

* feat: take control over exam instructions

* refactor: use fedx code structure

* fix: remove debug logging, remove redirect check

Co-authored-by: Vladas Tamoshaitis <vladas.tamoshaitis@raccoongang.com>

* Add state and reducer for check microfrontend_special_exams waffle flag (#4)

* feat: add state and reducer for check microfrontend_special_exams waffle flag

* fix: rename special exams enabled flag

* fix: rename reducer for setting special exams enabled flag

* refactor: timer feature

* feat(tests): extend tests + fix failing ones, fix quality

* fix: revert removing package lock file

Co-authored-by: Vladas Tamoshaitis <vladas.tamoshaitis@raccoongang.com>

* fix: naming of waffle flag helpers to reflect relation with mfe

* fix: change naming of the waffle flag

* fix: revert remove package lock file

* feat: switch to @edx npm package

* fix: Remove redundant references from .gitignore

* fix: add is_mfe_special_exams_enabled to courseMetadata.factory.js

* fix: fix tests for 'Sequence' content wrapped in 'SequenceExamWrapper'

Co-authored-by: Sagirov Eugeniy <sagirov19@gmail.com>
Co-authored-by: Vladas Tamoshaitis <vladas.tamoshaitis@raccoongang.com>
Co-authored-by: Sagirov Evgeniy <34642612+UvgenGen@users.noreply.github.com>
Co-authored-by: Igor Degtiarov <igor.degtiarov@raccoongang.com>
2021-05-24 08:44:01 -04:00
edX Transifex Bot
46056a0c53 fix(i18n): update translations 2021-05-24 02:08:01 +05:00
Carla Duarte
05b2439ff6 AA-816: update grade summary calculations (#456) 2021-05-19 15:44:49 -04:00
Carla Duarte
663bf7562b AA-814: course grade footer fix (#455) 2021-05-19 13:44:28 -04:00
Renovate Bot
58e29d81be fix(deps): update dependency react-redux to v7.2.4 2021-05-17 20:40:00 +00:00
Renovate Bot
027eeb8a49 chore(deps): update dependency glob to v7.1.7 2021-05-17 20:05:07 +00:00
Renovate Bot
e6a4bcd833 chore(deps): update dependency codecov to v3.8.2 2021-05-17 19:09:59 +00:00
Julia Eskew
1da461e2de feat: Support classifying certain errors as ignored errors in an MFE. (#447)
Ignored errors are sent to New Relic as page actions instead of JS errors,
allowing those errors to still be tracked as occurring but without causing
unnecessary alerts.

Ignored errors are configured per-MFE, *not* globally.

Bump the frontend-platform version to 1.10.2.

Add IGNORED_ERROR_REGEX variable for use in development. The actual
production value will be read from the YAML in edx-internal.

TNL-7924
2021-05-17 13:43:01 -04:00
Matthew Piatetsky
3c07cab8c2 [AA-746] fix: add hour/minute to assignment due dates and update dates timeline with branding styles (#450)
* fix: add hour/minute to assignment due dates and update dates timeline with branding styles

* update badge styles
2021-05-17 12:39:34 -04:00
Carla Duarte
110088688a fix: grade range bug (#449) 2021-05-17 09:23:49 -04:00
Matthew Piatetsky
d8243d6ea8 fix: remove currency symbol to avoid double currency symbol with some upgrade buttons (#448) 2021-05-14 09:48:50 -04:00
Carla Duarte
b1fdbcccf3 AA-790: progress tab handle unenrolled/unauthenticated users (#445) 2021-05-13 15:07:19 -04:00
edX Transifex Bot
00205d4b1f fix(i18n): update translations 2021-05-12 22:29:14 +05:00
julianajlk
6100f3ac2e fix: i18n translations for UpgradeCard (#442)
REV-2126 Value Prop
2021-05-12 09:50:08 -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
Kyle McCormick
88005ea5d2 refactor!: clean up unused references to lms_web_url (#409)
Before edx-platform version 19ba691,
only `lms_web_url` was exposed from the course
blocks API. Now that the API also exposes
`legacy_web_url`, we can stop falling back
to `lms_web_url` when `legacy_web_url` is
absent.

TNL-7796
2021-04-09 10:36:04 -04:00
Emma Green
e86f4a88cc small changes to the way the price is displayed on the course home page to pave the way for the expiration box 2021-04-07 12:02:48 -04:00
Kyle McCormick
cf58ff3d3f feat: Use legacy_web_url to redirect to legacy courseware (#404)
As part of making the new courseware experience the
default for staff, the LMS /jump_to/ links that are
exposed by the Course Blocks API via the `lms_web_url`
field will soon direct users to whichever experience
is active to them (instead of always directing to
the legacy experience & relying on the learner
redirect).

Because of this, the MFE can no longer rely on
`lms_web_url` to land a staff user to the legacy
experience. However, the aformentioned change
will also introduce a `legacy_web_url` field
to the API, which we *can* use for this purpose.

TNL-7796
2021-04-07 09:21:07 -04:00
edX Transifex Bot
32ac3632d0 fix(i18n): update translations 2021-04-04 17:06:51 -04:00
Diane Kaplan
6abf8531bb feat: ahead of value prop designs, update text to prep translations (REV-2127)
Co-authored-by: Diane Kaplan <dkaplan@edx.org>
2021-04-01 10:35:19 -04:00
Kyle McCormick
353964e75c feat: handle courseware paths more liberally (#395)
Valid courseware URLs currently include:
* /course/:courseId
* /course/:courseId/:sequenceId
* /course/:courseId/:sequenceId/:unitId

In this commit we add support for:
* /course/:courseId/:sectionId
* /course/:courseId/:sectionId/:unitId
* /course/:courseId/:unitId

All URL forms still redirect to:
  /course/:courseId/:sequenceId/:unitId

See ADR #8 for more context.

All changes:
* refactor: allow courseBlocks factory to build multiple sections
* refactor: make CoursewareContainer tests less brittle & stateful
* feat: handle courseware paths more liberally
* refactor: reorder, rename, & comment redirection functions

TNL-7796
2021-04-01 09:10:00 -04:00
Carla Duarte
6a376b20c7 AA-722: Progress Tab (#391) 2021-03-31 15:00:14 -04:00
Ben Warzeski
162f0ceeb5 Mmp2p 3 (#402)
* [MM-P2P] move access and meta logic to initialization and store in state

* move model access to thunk action
2021-03-31 10:43:38 -04:00
Ben Warzeski
9dcb91af9e mmp2p: handle missing verified-mode info after upgrade deadline (#401) 2021-03-30 11:18:49 -04:00
Kyle McCormick
4d1ed0f357 refactor: de-dupe factories between courseware and course-home (#400) 2021-03-29 16:05:51 -04:00
edX Transifex Bot
d94c7ad003 fix(i18n): update translations 2021-03-28 17:06:42 -04:00
Zachary Hancock
edef36becb update other course approved messaging (#398) 2021-03-26 13:33:37 -04:00
Matthew Piatetsky
29a13f729a fix: remove extra x in first section celebration modal (#397)
x icon that was added to paragon as part of the streak celebration modal is redundant with the x added to only the first section celebration modal
AA-713
2021-03-22 10:00:27 -04:00
edX Transifex Bot
6a6bddc5c8 fix(i18n): update translations 2021-03-21 17:06:30 -04:00
Carla Duarte
b12f184d18 AA-712: upsell link click tracking (#393)
* AA-712: course_home_audit_access_expires and in_course_audit_access_expires

* AA-712: course_home_welcome and in_course_welcome

* AA-712: course_home_dates

* AA-712: course_home_course_tools

* AA-712: course_home_upgrade_shift_dates and dates_upgrade

* AA-712: fixing up PR comments
2021-03-19 12:36:36 -04:00
Carla Duarte
45a68973b7 AA-711: implement upsell click events (#392) 2021-03-16 16:10:47 -04:00
Ben Holt
ab98cca421 Cleaned up all references to REV-1512's value prop experiment (#379)
Cleaned up all references to REV-1512's value prop experiment [REV-2123]
2021-03-15 14:31:21 -04:00
edX Transifex Bot
413b189293 fix(i18n): update translations 2021-03-14 17:06:28 -04:00
Matthew Carter
774b7bb1fc Set flyover visability default to true (#388) 2021-03-12 14:44:38 -05:00
Matthew Carter
ff93d7f4d4 Update language for last date to upgrade (#386) 2021-03-12 10:01:45 -05:00
Carla Duarte
e3d9ff9ed3 AA-213: Redirect progress tab to legacy (#384) 2021-03-11 15:46:37 -05:00
Ben Warzeski
6e2294e279 MMP2P G2 Activation (2nd try) (#382)
* upgrade paragon

* mmp2p experiment code

* mmp2p courseware triggers

* mmp2p course-home triggers

* mmp2p load styles

* mmp2p - add missed locator docstrings

* mmp2p test fixes

* add lazy loading for image-bearing components

* mmp2p experiment README

* mmp2p add lazy loading for sidecard

* generalize prices for currency options

* mmp2p fix flyover mobile args

* mmp2p fix lock paywall border display

* mmp2 - add safety-rail around verifiedmode access
2021-03-11 11:13:38 -05:00
Rebecca Graber
c17beeb908 WS-1740 logging, copy, style changes (#383) 2021-03-11 09:39:05 -05:00
Ben Warzeski
5c65627582 Revert "MM-P2P G2 Activation (#380)" (#381)
This reverts commit 8c0cafafa1.
2021-03-10 18:55:34 -05:00
Ben Warzeski
8c0cafafa1 MM-P2P G2 Activation (#380)
* upgrade paragon

* mmp2p experiment code

* mmp2p courseware triggers

* mmp2p course-home triggers

* mmp2p load styles

* mmp2p - add missed locator docstrings

* mmp2p test fixes

* add lazy loading for image-bearing components

* mmp2p experiment README

* mmp2p add lazy loading for sidecard

* generalize prices for currency options

* mmp2p fix flyover mobile args

* mmp2p fix lock paywall border display
2021-03-10 16:40:53 -05:00
Rebecca Graber
3450570d7e WS-1740 add course recommendations to celebration page (experiment) (#376)
* WS-1740 add course recommendations to celebration page (experiment)
2021-03-08 10:07:08 -05:00
edX Transifex Bot
c650283446 fix(i18n): update translations 2021-03-07 16:06:19 -05:00
Zachary Hancock
28773ce4c2 onboarding panel exam release date (#375)
* disable onboarding link for unreleased exam
2021-03-02 13:04:20 -05:00
Bianca Severino
e4b1d8088a Add "other course approved" and "expiring soon" states to proctoring info panel (#373) 2021-03-02 09:11:12 -05:00
edX Transifex Bot
1dc0669bae fix(i18n): update translations 2021-02-28 16:06:42 -05:00
Carla Duarte
58eb9fe23c AA-651: fix dates tab undefined property (#372) 2021-02-26 15:59:49 -05:00
Carla Duarte
38617c827e AA-684: sequence link to legacy or MFE (#371) 2021-02-26 12:00:10 -05:00
Matthew Piatetsky
a9939a1b5e fix three day streak bug with incorrect variable (#370) 2021-02-25 12:05:33 -05:00
Matthew Piatetsky
2525805aac feat: Create three day streak celebration (#354)
Show learners a celebratory modal if they visit the learning mfe for 3 days in a row. Call edx-platform API to determine if they should see the celebration.
AA-304
2021-02-22 14:34:28 -05:00
Ben Warzeski
26a7b3b0de tie p2p action only to targetted date-summary entry if there are multiple (#367) 2021-02-22 13:48:43 -05:00
Ben Warzeski
4bbc29591c fix conditional for injecting experiment method (#366) 2021-02-22 12:33:27 -05:00
Carla Duarte
49bfc65a03 fix null course goal object bug (#365) 2021-02-18 12:17:32 -05:00
Michael Terry
d017c3194e feat: Show effort estimation if the backend provides it (#357)
AA-614
2021-02-16 14:36:05 -05:00
Ben Warzeski
a2ccedcecd [MM-P2P] optimizely experiment overrides for course_home (#364)
* alert override

* course home date overrides

* add undefined check for experiment hook

* inject intl rather than passing
2021-02-16 12:01:04 -05:00
Renovate Bot
3a7c455bb3 fix(deps): update font awesome 2021-02-08 22:05:08 +00:00
Renovate Bot
19087417b4 fix(deps): update dependency @edx/frontend-platform to v1.8.4 2021-02-08 21:17:06 +00:00
Renovate Bot
05c6878644 fix(deps): update dependency @edx/frontend-component-footer to v10.1.4 2021-02-08 20:09:56 +00:00
Matthew Piatetsky
7e2f495f52 Use contains_content_type_gated_content attribute, rather than the graded attribute, to determine if the content type gating paywall should be displayed. (#349)
The issue was that items with the graded attribute are not always going to be paywalled by content type gating.
AA-613
2021-02-05 12:29:08 -05:00
Dillon Dumesnil
50e649daa3 AA-491: Update MFE with analytics event (#351)
These already exist in the legacy view. This is just porting them
over into the MFEs
2021-02-02 05:39:27 -08:00
Dillon Dumesnil
629382f719 AA-492: Add event data for consumption in the backend (#355) 2021-02-01 09:46:21 -08:00
edX Transifex Bot
8835a9cd6a fix(i18n): update translations 2021-01-31 16:05:18 -05:00
Simon Chen
3e2eebdd9b MST-621 (#353)
The text in the onboarding panel for submitted state is confusing. This PR updated the text so learners understood the wait
2021-01-28 11:54:05 -05:00
Carla Duarte
acd2cc3222 AA-638: course completion bug (#352) 2021-01-27 13:59:06 -05:00
Michael Terry
9ef3787d4b AA-410: Make sure alerts are cleaned up after being added (#324) 2021-01-25 12:41:32 -05:00
Carla Duarte
47fd6bfe18 AA-512: underline links within iframes (#347) 2021-01-25 11:11:14 -05:00
edX Transifex Bot
984010a8ec fix(i18n): update translations 2021-01-24 16:04:53 -05:00
Michael Terry
58543a34b3 Separate courses redux model into courseHomeMeta and coursewareMeta (#348) 2021-01-22 15:28:16 -05:00
alangsto
293dc9f4c3 Updated broken links (#346)
updated tests

added test
2021-01-21 14:54:18 -05:00
alangsto
68c8d31dd1 added component for proctoring panel (#345)
added 404 test

updated for feedback

updated test
2021-01-21 12:15:56 -05:00
Carla Duarte
96ef87886f AA-361: shift dates banner tests (#344) 2021-01-19 17:09:29 -05:00
Carla Duarte
48e3f43062 AA-598: canEnroll loop bug fix (#343) 2021-01-11 16:53:22 -05:00
Carla Duarte
d74557d681 AA-481: Course home outline UI fixes (#341) 2021-01-11 15:30:49 -05:00
edX Transifex Bot
0e18e0908a fix(i18n): update translations 2021-01-10 16:05:20 -05:00
Carla Duarte
958c13ca93 AA-465: Various a11y fixes (#340) 2021-01-07 12:11:28 -05:00
Carla Duarte
26de2cebeb AA-545: Add course in progress CourseExit variant (#334) 2021-01-06 15:10:29 -05:00
Matthew Piatetsky
bd8496a5e2 move flyover button toggle within frontend-app-learning (#339) 2021-01-06 12:31:00 -05:00
Matthew Piatetsky
aa56239f54 add empty div to fix bug with moving flyover toggle 2021-01-06 11:45:00 -05:00
Emma Green
a0b85111eb change the flyover icon underline to 2px from UX feedback 2021-01-06 10:04:22 -05:00
Dillon Dumesnil
07b252ecc6 AA-501: Send query param to recheck access to xblocks for render (#335) 2021-01-06 06:49:25 -08:00
David Joy
92b364e0f8 Upgrading frontend-platform to latest. (#336) 2021-01-05 16:14:34 -05:00
Emma Green
9247eb3098 fix grammer for value prop experiment for gated upsell 2021-01-04 11:50:09 -05:00
Emma Green
9e0f5d7e22 add black underline when value prop flyover is open 2021-01-04 11:50:09 -05:00
edX Transifex Bot
db4b4d18cc fix(i18n): update translations 2020-12-27 16:04:43 -05:00
julianajlk
92a464b2da Update REV-1512 experiment gated content (#329)
REV-1512
2020-12-22 10:08:00 -05:00
Matthew Piatetsky
7fed8db02a fix button interactivity (#328) 2020-12-21 13:01:47 -05:00
Matthew Piatetsky
8161f4d9a0 fix x button interactivity (#327) 2020-12-21 12:13:27 -05:00
Ben Holt
c6661e71b1 Add span for mobile return text and standardize on notifications title for localization (#325) 2020-12-21 11:02:39 -05:00
edX Transifex Bot
6dc7ff761a fix(i18n): update translations 2020-12-20 16:04:36 -05:00
Matthew Piatetsky
de4a7d9f34 Update business logic for the flyover per UX feedback (#316) 2020-12-17 12:51:19 -05:00
Ben Holt
5f239583fd Extract flyover component and make a version for mobile (#315) 2020-12-17 11:34:17 -05:00
Renovate Bot
aff49aa8a9 fix(deps): update dependency @edx/frontend-component-footer to v10.1.1 2020-12-17 16:24:06 +00:00
David Joy
6e10bffd40 fix: bumping frontend-build version to 5.5.5 (#319) 2020-12-17 11:22:34 -05:00
Michael Terry
37a4dcce18 AA-449: Show discounted price in upgrade buttons & course exit (#311)
If an offer is active for the user, show the discounted price (and
a struck-out original price) on upgrade buttons in the course sock
and outline sidebar.

Also show the discount code and price in the course exit upgrade
screen.
2020-12-17 10:18:57 -05:00
renovate[bot]
c16da21602 chore(deps): update dependency @edx/frontend-build to v5.5.4 (#317)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2020-12-17 10:16:33 -05:00
renovate[bot]
55ea84f9a6 fix(deps): pin dependencies (#215)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2020-12-17 10:09:05 -05:00
Carla Duarte
4341a828db AA-131: Landing page for anonymous or un-enrolled users (#281) 2020-12-16 15:50:17 -05:00
Matthew Piatetsky
255e36baa8 [REV-1517] Add Notification header and X to hide the flyover (#313)
* Add back classes to first purchase offer banner that are used in the REV-1512 optimizely test

* Add notification header with an X to hide the flyover box
2020-12-16 12:35:05 -05:00
Renovate Bot
2fc4c8c153 chore(deps): update dependency @edx/frontend-build to v5.5.3 2020-12-15 23:50:16 +00:00
Matthew Piatetsky
79f5c7fcf4 Add back classes to first purchase offer banner that are used in the REV-1512 optimizely test (#309) 2020-12-15 14:59:56 -05:00
Carla Duarte
743621ff51 Dates tab badge fix (#310) 2020-12-15 12:06:53 -05:00
Carla Duarte
9272498c9e AA-417: Update cert images (#308) 2020-12-15 10:24:27 -05:00
Michael Terry
e89aef78b5 TNL-7185: Stop using dangerouslySetInnerHTML in alerts (#306)
Render offer and access-expiration alerts ourselves from newly
passed in backend data, rather than from provided HTML blobs.
2020-12-14 16:09:49 -05:00
edX Transifex Bot
3d41c56a0a fix(i18n): update translations 2020-12-13 16:04:55 -05:00
JJ
e4060b7481 [REV-1521] Add new lock paywall component for the Value Prop experiment (#293)
Add a new lock paywall component to be shown as part of the Purchase squad's Value Prop Optimizely experiment.
2020-12-11 11:31:07 -05:00
Renovate Bot
4e92053151 Update dependency @edx/frontend-build to v5.5.2 2020-12-10 22:33:33 +00:00
Michael Terry
38700499d4 AA-450: add catalog search link to bottom of course exit (#299) 2020-12-10 11:16:35 -05:00
Matthew Piatetsky
0e3fc032ab Add flyover box and button to toggle it for the REV1512 experiment (#291)
This part would be trickier to do in optimizely so adding it in react
REV-1512
2020-12-10 11:04:05 -05:00
Dillon Dumesnil
a604e0be10 AA-503: Course Celebration view for users in verification pending state (#301)
Learners were having questions when we would continue showing them the
'Verify Now' button if they had a submitted a verification attempt
already.
2020-12-09 14:37:23 -05:00
Carla Duarte
264f36b89e AA-504: Dates tab rebrand fix (#303) 2020-12-09 14:36:39 -05:00
stvn
2319a7dfb0 Merge PR #302 kill/travis
* Commits:
  Remove Travis-CI integration
2020-12-09 10:54:31 -08:00
stvn
6549e2b8a2 Remove Travis-CI integration
This is admittedly the much abridged version, but...
Due to changes at Travis-CI that lead to prohibitively slow build/wait
times, we (edx) decided to switch CI/validation provider to Github
Actions [1].

While we had originally intended to keep the integration available,
following Travis-CI's drop in available open-source credits, it no
longer appears to be a viable option for the community.

As such, and given our already present vendor lock-in at Github,
we recommend using Github Actions for CI/validation in this repo.

However, for those not hosting in the Github ecosystem, you can
configure your testrunner to run `make validate.ci`, for the same
effect; the Github Action is just a wrapper for this call.

- [0] Fixes: TNL-7784
- [1] #276
2020-12-09 10:44:32 -08:00
David Joy
ddfc88dad6 Miscellaneous rebranding fixes (#300)
* Rebranding course license

- Fixing horizontal padding
- Setting color to gray 500
- Removing unnecessary a:hover color
- Removing unneeded CSS classes (sequence-footer, course-license, and license-text)
- Removing unnecessary spans

* Removing all usages of theme-color.

* Removing underline from “Help” button in header

* Use outline-primary for user menu button in header.

* Revert text-decoration-none on Help link

This is unnecessary now - it’s fixed at the brand level.

* Rebranding breadcrumbs to be primary-500.

* Set styling for Unit title to h3.

* Setting bookmark button color to primary-500

* Aligning current selection line to be inside dropdown, not outside.

* Adjusting sequence dropdown colors

Gray 700 for non-active units, primary 500 for active unit icon.

* Remove custom btn-outline-light config

We no longer use it in this MFE and the color palette in paragon/the brand has changed anyway.

* Let the stylesheet breathe!
2020-12-08 17:13:23 -05:00
stvn
e1e3d4992d Merge PR #298 fix/codecov
* Commits:
  Run coverage via Github Action
2020-12-08 10:09:57 -08:00
David Joy
43b0f2fbf0 Darken sequence nav buttons so they don’t appear disabled (#297) 2020-12-08 12:47:48 -05:00
stvn
64ff72faa9 Run coverage via Github Action
Note: I tried directly invoking `codecov`, but it "couldn't tell" that
it was running in a GA/CI container, so I've opted for using the GA
codecov integration, which does work as expected.
2020-12-08 09:31:27 -08:00
stvn
cc0af77e2f Merge PR #276 add/github-workflow
* Commits:
  Add Github workflow for CI tests
  Use common CI test target via Travis
  Consolidate CI test scripts in Makefile
2020-12-08 08:55:29 -08:00
stvn
7086bdc9ab Add Github workflow for CI tests 2020-12-08 08:48:56 -08:00
stvn
e627fd6f27 Use common CI test target via Travis 2020-12-08 08:47:31 -08:00
stvn
2ba9440966 Consolidate CI test scripts in Makefile 2020-12-08 08:46:57 -08:00
David Joy
cd51206462 Fixes tab color, according to rebrand UX feedback (#296) 2020-12-08 11:13:12 -05:00
Michael Terry
0cb97db7eb AA-383: add outline sidebar upgrade card (#289)
Also adds the course sock to the outline page.
2020-12-08 10:58:57 -05:00
Renovate Bot
421b438569 fix(deps): update dependency @fortawesome/react-fontawesome to v0.1.13 2020-12-08 00:29:54 +00:00
Renovate Bot
1d40baf4cd chore(deps): update dependency es-check to v5.1.4 2020-12-07 23:36:14 +00:00
David Joy
3c5fb46a4d Update MFE to use the new “brand-openedx” repository. (#283)
* Update MFE to use the new “brand-openedx” repository.

This will allow the MFE to be re-branded by overriding this default implementation.  More detail here:

https://github.com/edx/brand-openedx

* Removing unused frontend-component-header module.

This app doesn’t use frontend-component-header.  That it was a dependency is confusing and led me to believe I needed to wait for its rebrand to continue - not so.  Removing the unused dependency.

* Adding quick comment describing the structure of gated_content

* Fixing course exit styling

* Changing LinkedIn icon

* fix: fix Instructor toolbar button styling

* Bumping footer, platform, and paragon versions.

* Using configured logo and favicon.

Co-authored-by: Carla Duarte <cduarte@edx.org>
2020-12-07 17:50:06 -05:00
Michael Terry
5f06d726f7 AA-478: correctly refresh outline data when refreshing dates (#292) 2020-12-07 14:09:44 -05:00
Michael Terry
bcd69f5836 AA-463: move welcome message below banners on outline tab (#290) 2020-12-07 09:51:30 -05:00
Michael Terry
654fd4c35c AA-483: Fix some course exit segment event calls to work again (#288) 2020-12-04 10:33:45 -05:00
Michael Terry
b98a87c1f5 AA-476: Send org_key with tracking events (#286)
Also convert the course tool click event from accidentally being
a segment event into a log event, like it is on the LMS.
2020-12-03 12:44:52 -05:00
Zainab Amir
0d29082793 Add suppression to username in header (#287) 2020-12-03 15:39:16 +05:00
Dillon Dumesnil
2d56bc5953 AA-448: UI fixes (#285)
LinkedIn button size and single column for view grades button in mobile
2020-12-02 04:59:16 -08:00
David Joy
1bfe3f4436 Use prereqSectionName for the name of the prereq section. (#284)
Surprise, surprise.

Not sure how this wasn’t correct.  The bug results in the content lock UI showing the name of the current section rather than the prerequisite when describing… the prerequisite.
2020-12-01 10:04:59 -05:00
edX Transifex Bot
3f9f40800a fix(i18n): update translations 2020-11-22 16:04:56 -05:00
Renovate Bot
a1c7969477 fix(deps): update dependency @edx/frontend-component-header to v2.0.6 2020-11-19 20:24:49 +00:00
renovate[bot]
53cf637938 chore(deps): update dependency @edx/frontend-build to v5.4.0 (#247)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2020-11-19 13:13:22 -05:00
stvn
329bcba31c Merge PR #275 add/event/tracking-logs
* Commits:
  Submit events to the tracking logs, not just Segment
2020-11-17 12:46:26 -08:00
stvn
122cef6053 Submit events to the tracking logs, not just Segment
Due to an unfortunate naming choice, we apparently had _not_ been
sending navigation events to the tracking logs' `/track` backend, but
just to the Segment.io tracking backend.

Note: There are still other portions of this repo _outside_ the learning
sequence (Course Tools, Celebration, etc.) that only send to Segment and
not the tracking logs, though maybe this is by design, those being "new"
events?
2020-11-17 11:05:14 -08:00
Carla Duarte
a8a8cf5862 AA-199: Program Completion in CourseExit page (#271) 2020-11-16 15:03:45 -05:00
edX Transifex Bot
74149c2c54 fix(i18n): update translations 2020-11-15 16:04:56 -05:00
Michael Terry
15975fdd78 AA-356: add more config overrides for branding (#270)
Remove some edX-specific branding / links (like support URLs) in
favor of values from configuration.

Images (sample certificates) are still branded for now. We can get
them later.
2020-11-10 10:24:33 -05:00
David Joy
99f0a4a208 fix: make LTI modals full-screen (#268)
Fixes TNL-7410

This causes LTI modals in courseware to take up the whole screen.  It does this by creating a new “dialogClassName” value that we then use to override the default heights/widths of the Bootstrap modal.

We also remove the title of the iframe, which just takes up space and detracts from the LTI content.
2020-11-03 16:04:41 -05:00
edX Transifex Bot
4f9cd060be fix(i18n): update translations 2020-11-01 16:04:56 -05:00
Michael Terry
cd1d3dd379 AA-402: send segment events for course-exit (#265)
Specifically, a "I visited this page" event, with information on
which variant was seen. And "I clicked this button" events, for
our various calls to action.
2020-10-29 09:55:14 -04:00
Renovate Bot
b08f3d7b45 chore(deps): update dependency es-check to v5.1.2 2020-10-28 01:24:55 +00:00
Carla Duarte
1531f3e912 AA-390: Add Social Share icons to Course Exit (#259) 2020-10-27 14:58:35 -04:00
Michael Terry
6f415544be AA-197: Handle non-cert learners that can upgrade (#263)
Tell them about verified certificates and link to ecommerce.

Also fixes AA-376 by handling the no-verified-mode-to-upgrade-to
case.
2020-10-27 11:28:52 -04:00
Renovate Bot
4eb52a592d fix(deps): update dependency react-redux to v7.2.2 2020-10-27 00:35:14 +00:00
Renovate Bot
b4823b90e7 fix(deps): update dependency @fortawesome/react-fontawesome to v0.1.12 2020-10-26 15:20:56 +00:00
edX Transifex Bot
5602c0a3b3 fix(i18n): update translations 2020-10-25 17:04:55 -04:00
Dillon Dumesnil
e0e53f24f1 AA-397: Course Exit Tests (#257)
This also renames the CourseExit url from /course/{course_id}/course-exit
to /course/{course_id}/course-end for better UX.
2020-10-23 10:01:42 -07:00
Renovate Bot
9d8c687e4d fix(deps): update dependency @edx/paragon to v12.0.5 2020-10-23 15:50:47 +00:00
Michael Terry
aeb6a3ebb4 Revert "Revert "AA-403: use PageRoute, not Route (#252)" (#253)" (#254)
This reverts commit e2aa00b16d.
2020-10-20 15:20:10 -04:00
Michael Terry
e2aa00b16d Revert "AA-403: use PageRoute, not Route (#252)" (#253)
This reverts commit ac711d5f3d.
2020-10-20 15:06:05 -04:00
Michael Terry
ac711d5f3d AA-403: use PageRoute, not Route (#252)
This way we get frontend-platform's automatic segment page tracking.
2020-10-20 14:38:19 -04:00
Dillon Dumesnil
9d8b5d21b5 AA-385: Add LinkedIn Add to Profile Button to Course Celebrate (#249) 2020-10-20 07:30:18 -07:00
Michael Terry
15ae6d4981 AA-362: Add tests for outline tab alerts (#250) 2020-10-20 10:03:30 -04:00
Carla Duarte
f063495cbb AA-381: Fix shorten welcome message bug (#251) 2020-10-19 16:31:05 -04:00
Michael Terry
c5821faee8 AA-377: Add non-passing course exit screen (#246)
- Adds a non-passing cert learner course exit screen
- Moves all the logic about what course-exit mode we're in into
  a utility method in the course-exit folder
- Moves all the logic about how the 'Next' button should read into
  a utility method in the course-exit folder
2020-10-19 11:00:44 -04:00
edX Transifex Bot
f83a6e574c fix(i18n): update translations 2020-10-18 17:04:55 -04:00
Renovate Bot
03661ccf4b fix(deps): update dependency @edx/frontend-platform to v1.5.4 2020-10-15 22:24:59 +00:00
Carla Duarte
2d5af74b1b AA-357: Add reset dates banner to outline tab (#243) 2020-10-15 13:44:04 -04:00
Carla Duarte
ae8141c1a8 AA-388: CourseCelebration UI improvements (#242) 2020-10-15 10:37:53 -04:00
Carla Duarte
e9cf5e58de AA-388: CourseExitPage UI improvements (#237) 2020-10-14 10:34:55 -04:00
David Joy
1950fe56bd TNL-7299 - Hide sequences that are time limited (special exams) (#241)
If a sequence has its isTimeLimited flag set, then show a spinner instead of the sequence content.  The CoursewareContainer, meanwhile, will be attempting to redirect to the legacy experience.

This prevents a situation where we temporarily show proctored/special exam content to users before their exam starts.
2020-10-14 10:11:14 -04:00
Renovate Bot
94cacb14e7 chore(deps): update dependency @edx/frontend-build to v5.2.7 2020-10-10 16:04:46 +00:00
Renovate Bot
a8dea78e24 chore(deps): update dependency @edx/frontend-build to v5.2.6 2020-10-10 15:17:05 +00:00
Renovate Bot
8adcfb040a fix(deps): update dependency @edx/paragon to v12.0.4 2020-10-07 18:05:43 +00:00
David Joy
4f396737e4 Fixes TNL-7613 and TNL-7614 (#236)
The course blocks API accepts a value of ‘special_exam_info’ in its requested_fields parameter.  This value is necessary to include special exam sequences for non-staff users.  Special exams include timed, proctored, and practice proctored exams.
2020-10-06 17:17:29 -04:00
Dillon Dumesnil
36f8dd81cd Quick fix to add slice to other routes using TabContainer (#235) 2020-10-06 11:57:18 -07:00
Dillon Dumesnil
f6aebc7d29 AA-389: Updating Routing for CourseExit Page (#234)
The CourseExit page uses the TabContainer because it seems to be a better solution
than adding it into the paths for the CoursewareContainer, despite the CoursewareContainer
already doing the correct fetch. The reason for this is because within the
CoursewareContainer, it would still be necessary to check for the /course-exit path (due
to the CoursewareContainer trying to greedily match sequenceId (read: it will try and
look up 'course-exit' as a sequence)). That effectively defeats the purpose of using the
routing in the first place so instead, we place it in a TabContainer.

The InstructorToolbar didMount logic became necessary once we had a page (CourseExit) that does a redirect on a quick exit.
As a result, it unmouunts the InstructorToolbar (which will be remounted by the new component),
but the InstructorToolbar's MasqueradeWidget has an outgoing request. Since it is unmounted
during that time, it raises an error about a potential memory leak. By stopping the render
when the InstructorToolbar is unmounted, we avoid the memory leak.
2020-10-06 10:42:48 -07:00
Renovate Bot
8a63aef3f0 fix(deps): update dependency @fortawesome/fontawesome-svg-core to v1.2.32 2020-10-05 20:07:48 +00:00
edX Transifex Bot
4be37ceb14 fix(i18n): update translations 2020-10-04 17:09:55 -04:00
Carla Duarte
d123fe6229 AA-363: Tests for Outline Tab and Widgets (#222) 2020-10-02 16:48:42 -04:00
Nick
2f738fdba4 AA-196 course celebration cert (#197)
* AA-196 course celebration cert

* AA-196: Course Celebration for passing Verified Learners

Co-authored-by: Dillon Dumesnil <ddumesnil@edx.org>

Note: This PR is being merged in somewhat incomplete as we decided to split off the work into a couple of other tickets. For example, the UI styling is not complete and I plan to also take another look at the routing. These code paths are not in use yet as the `courseExitPageIsActive` will always be False.
2020-10-02 07:27:59 -07:00
Michael Terry
d52aa3246e Revert "Revert "AA-291: Add Optimizely (#219)" (#227)" (#229)
This reverts commit 42715d3de2.
2020-10-01 10:38:09 -04:00
stvn
e6e5258e5b Merge PR #228 log/unexpected-course-block
* Commits:
  Log "Section ... has child block" as info, not error
  Log "Unexpected course block type" as info, not error
2020-09-30 14:59:53 -07:00
stvn
753925ba99 Log "Section ... has child block" as info, not error 2020-09-30 14:03:01 -07:00
stvn
684be8c0cf Log "Unexpected course block type" as info, not error 2020-09-30 13:36:22 -07:00
Michael Terry
42715d3de2 Revert "AA-291: Add Optimizely (#219)" (#227)
This reverts commit f91abd319f.
2020-09-29 16:05:13 -04:00
Renovate Bot
4d21633462 fix(deps): update dependency @fortawesome/fontawesome-svg-core to v1.2.31 2020-09-29 17:16:25 +00:00
Carla Duarte
f91abd319f AA-291: Add Optimizely (#219) 2020-09-29 11:19:22 -04:00
Renovate Bot
43be11c636 fix(deps): update dependency @edx/paragon to v12.0.1 2020-09-28 20:15:39 +00:00
edX Transifex Bot
72df79b9b8 fix(i18n): update translations 2020-09-27 17:04:45 -04:00
Michael Terry
6f331ea6d5 AA-253: handle the backend giving less data (#223)
Specifically, no outline tree or no course tools
2020-09-25 16:50:30 -04:00
Renovate Bot
d137d5682d chore(deps): update dependency @edx/frontend-build to v5.2.3 2020-09-25 14:39:48 +00:00
Renovate Bot
ea9f5254b7 fix(deps): update dependency @edx/frontend-platform to v1.5.3 2020-09-23 03:24:13 +00:00
Renovate Bot
577a19e35c chore(deps): update dependency es-check to v5.1.1 2020-09-22 06:31:10 +00:00
edX Transifex Bot
f58d405b3b fix(i18n): update translations 2020-09-20 17:04:33 -04:00
David Joy
927d424d33 Agrendalath/bb 2599 low priority tests (#214)
* [TNL-7269] WIP low priority tests

* [TNL-7269] Add low priority tests

* [TNL-7269] Fix failing EnrollmentAlert tests

* [TNL-7269] Address review comments

* Fixing test errors on rebase with master.

Co-authored-by: Agrendalath <piotr@surowiec.it>
2020-09-18 09:27:41 -04:00
Kyle McCormick
25e5d39a72 Refer to 'Existing experience' as 'Legacy experience' (#213) 2020-09-17 13:50:57 -04:00
Michael Terry
d8ed3d6bf8 AA-265: Add more tests for the dates tab (#207)
* Prepare some initial test refactoring

- Expand test data for course home metadata
- Don't test courseware metadata in course-home redux tests

This is Dillon's work.

* AA-265: Add more tests for the dates tab

Add an ADR to talk about how we want to test in this repo.

And refactor the fake dates tab data used for debugging to also be
used for tests.
2020-09-17 11:01:33 -04:00
Carla Duarte
c5a43524a1 AA-368: Expand course outline section that contains resume block (#212) 2020-09-16 13:46:06 -04:00
Alex Dusenbery
adfc2d568b fix: bump frontend-enterprise to 4.2.3 2020-09-16 10:03:21 -04:00
Carla Duarte
4c6797c631 AA-128: Course Home UI/UX improvements (#208) 2020-09-16 09:29:58 -04:00
edX Transifex Bot
e2710f6ed3 fix(i18n): update translations 2020-09-13 17:05:12 -04:00
Jeff LaJoie
1b859f4ab6 ENT-3181: Enables custom branding for enterprise customers in logo and user menu 2020-09-11 09:15:39 -04:00
Dillon Dumesnil
f44ce4c311 AA-320: Adding in UTM parameters to sharing links (#204) 2020-09-10 08:14:30 -07:00
edX Transifex Bot
d8dbbaa7a2 fix(i18n): update translations 2020-09-06 17:04:58 -04:00
Michael Terry
ddc85f2fd3 AA-306: Update twitter hashtag for celebrations (#201)
It's now #myedxjourney instead of #mooc.

Also, fixed the wording of the email share option.
2020-09-03 11:17:42 -04:00
Michael Terry
9cbe0b7c8b AA-126: Update outline tab to be a little closer to LMS (#198)
- Changed it to use its own normalizeBlocks call (and stop sharing
  with courseware)
- Add green checkmarks for complete blocks
- Added icons, descriptions, and due dates for subsections
- Updated look of subsections to match LMS a bit more
2020-08-31 16:44:30 -04:00
Carla Duarte
c83389e7c5 AA-125: quick fix (#199) 2020-08-31 13:50:20 -04:00
Carla Duarte
37d56b4197 AA-125: Course Goals in Course Home Outline Tab (#190) 2020-08-31 09:58:34 -04:00
Renovate Bot
01f69e2273 fix(deps): update dependency @edx/paragon to v10.1.1 2020-08-27 21:21:46 +00:00
Michael Terry
c5ada7e974 AA-326: hide dates banner if the upgrade link is null (#195)
This will prevent a pointless & broken button when the upgrade
deadline has passed for the course.
2020-08-25 09:04:06 -04:00
edX Transifex Bot
450d1c1861 fix(i18n): update translations 2020-08-23 17:04:33 -04:00
David Joy
aa10b3f600 Use ‘modal-lg’ for LTI modals. Update Paragon. (#193)
The new version of Paragon includes a “dialogClassName” property on Modal which lets us set the modal width to ‘modal-lg’ or ‘modal-sm’ - we’re using the former here.
2020-08-21 16:28:02 -04:00
Michael Terry
b65bd0ff44 AA-305: Update Masquerade Widget UI (#188)
* AA-305: Update Masquerade Widget UI

This will start showing the user being masqueraded as in the Toolbar
and will show any error messages next to the Toolbar as well.

* Further masquerade widget fixes

Co-authored-by: Dillon Dumesnil <ddumesnil@edx.org>
2020-08-21 12:49:12 -04:00
David Joy
86d28136de Paragon 10: Updating Dropdown and Button usages. (#187)
* Updating Dropdown and Button.

* Fixing broken tests and test warnings.

* Remove comment block.

* Using variant=“link” on the Tabs Dropdown.Toggle.

* Fixing some merge conflicts.
2020-08-20 13:46:44 -04:00
Alex Dusenbery
707fcc2aa1 feat: ent-3273 | Don't show order history in dropdown if user is associated with a subscription
Update frontend-enterprise dependency to 4.2.2
2020-08-20 11:31:52 -04:00
Kyle McCormick
caabf6a54c Prepend dev BASE_URL with http:// to fix login redirects (#189) 2020-08-20 08:35:16 -04:00
Dave St.Germain
d910d09e00 Allow units to create a modal in the parent window (#184)
This is used by LTI blocks configured to launch in a modal window. Instead of opening a modal in our unit iframe, the component will send a message to the parent window (the courseware mfe) requesting it to open its own modal, containing a URL to launch the LTI tool.
2020-08-19 13:08:13 -04:00
edX Transifex Bot
de53ed9258 fix(i18n): update translations 2020-08-16 17:08:26 -04:00
daphneli-chen
81188ae30f AA-274: Created credit requirements banner (#168)
Co-authored-by: Daphne Li-Chen <dli-chen@edx.org>
2020-08-14 16:50:10 -04:00
stvn
f5d361661f Merge PR #183 add/kill-switch
* Commits:
  Implement kill-switch for non-staff users
2020-08-13 14:28:30 -07:00
David Joy
c298bc1dbf Implement kill-switch for non-staff users
to redirect to the current unit’s lmsWebUrl if the MFE is disabled

If we receive an error_code of 'microfrontend_disabled',
go to the equivalent unit in the LMS experience.

Fixes: TNL-7362
Co-authored-by: stvn <stvn@mit.edu>
2020-08-12 17:26:30 -07:00
Kyle McCormick
0ad80a63cf Fix lint warnings; require 0 warnings for Travis build (#181) 2020-08-12 17:16:28 -04:00
Kyle McCormick
bc76adf8eb Wrap all alert payloads in useMemo to avoid infinite re-rendering (#182)
* Wrap all alert payloads in useMemo to avoid infinite re-rendering

This manifested in production as a browser freeze for any
user who saw the 15%-off-to-upgrade message.

TNL-7400

* fixup! meant to say identity, not equality
2020-08-12 17:12:31 -04:00
David Joy
cc7142e5c1 Fix checkBlockCompletion parameters
We were assuming a prop named unitId existed in CoursewareContainer - it doesn’t.  unitId is not in redux.  What we do have, is the unitId in the route params - what we refer to as routeUnitId.  If we use this instead of the non-existent unitId, then life is good.

I wrote a test (that breaks!) prior to implementing the fix.  The fix satisfies the test. 🎉
2020-08-12 16:01:12 -04:00
Dillon Dumesnil
a975b8ae70 Format may not be defined. Provide a default and only add to URL if it exists (#178) 2020-08-12 09:49:21 -07:00
Renovate Bot
3b34a87391 Pin dependency @edx/paragon to 9.1.1 2020-08-12 12:38:20 -04:00
Nick
453f56c7c8 AA-287 cta events (#177) 2020-08-12 11:48:46 -04:00
Dillon Dumesnil
a131a9f9fb Add format into iframe request to populate context (#174)
The format is used with the due date in the vertical to show
text like "Homework due ___"
2020-08-11 08:32:24 -07:00
Carla Duarte
d5f9af1954 AA-129: Resume Course button (#133) 2020-08-11 11:08:29 -04:00
Carla Duarte
a44d2633a1 AA-255: Dates Tab Tooltip for ORA (#173) 2020-08-10 09:37:01 -04:00
edX Transifex Bot
de2a46eb93 fix(i18n): update translations 2020-08-09 17:08:11 -04:00
Nick
9a315aa29d AA-264 mfe courseware reset dates (#165)
- add toast display after successful reset of dates via banner.
2020-08-06 15:20:53 -04:00
stvn
ad74b2295b Merge PR #106 add/masquerade-username
* Commits:
  Add "masquerade as specific student" support
2020-08-06 12:06:47 -07:00
stvn
c8961d3777 Add "masquerade as specific student" support 2020-08-06 09:07:53 -07:00
Dillon Dumesnil
c7c401e385 AA-275: Persist if the original user was staff for Instructor Toolbar (#167)
We want to be able to know if both the original user is a Staff user
as well as if the user being masqueraded as is staff. This updates
to accept both of these fields
2020-08-06 08:58:47 -07:00
Michael Terry
9e0f192ae7 AA-278 & AA-279: Add offer and course expired alerts to outline (#164)
* AA-278: Add offer alert to outline

It was previously only used in the courseware. But to match the
LMS, we also want to show it on the outline tab.

* AA-279: Add course expired alert to outline

It was previously only used in the courseware. But to match the
LMS, we also want to show it on the outline tab.
2020-08-06 09:49:37 -04:00
daphneli-chen
4667535c0c AA-211: Created certificate banner and studio link (#134)
Co-authored-by: Daphne Li-Chen <dli-chen@edx.org>
2020-08-05 10:26:34 -04:00
Renovate Bot
a1646c5793 Update dependency regenerator-runtime to v0.13.7 2020-08-04 06:58:42 +00:00
Renovate Bot
0e6f64081d Update dependency react-share to v4.2.1 2020-08-04 04:38:41 +00:00
Renovate Bot
8e4f0535a7 Update dependency react-redux to v7.2.1 2020-08-04 03:20:10 +00:00
Renovate Bot
6f14f01dc2 Update dependency codecov to v3.7.2 2020-08-04 01:42:40 +00:00
Renovate Bot
6233f16812 Update dependency @testing-library/user-event to v12.0.17 2020-08-04 00:10:16 +00:00
Renovate Bot
a60480ec52 Update dependency @fortawesome/fontawesome-svg-core to v1.2.30 2020-08-03 22:15:38 +00:00
Renovate Bot
eba9d9a44d Update dependency @edx/frontend-platform to v1.5.2 2020-08-03 20:30:07 +00:00
Renovate Bot
8cd3d6501f Update dependency @edx/frontend-component-footer to v10.0.11 2020-08-03 19:52:06 +00:00
dependabot[bot]
76fc6d64f2 Bump codecov from 3.7.0 to 3.7.1
Bumps [codecov](https://github.com/codecov/codecov-node) from 3.7.0 to 3.7.1.
- [Release notes](https://github.com/codecov/codecov-node/releases)
- [Commits](https://github.com/codecov/codecov-node/compare/v3.7.0...v3.7.1)

Signed-off-by: dependabot[bot] <support@github.com>
2020-08-03 14:40:37 -04:00
Michael Terry
314b82d0b2 AA-276: Add course start alert in outline (#146)
Also move a few of the outline alerts into the course home
source folder.
2020-08-03 13:39:25 -04:00
Michael Terry
e17b66851e AA-277: Add certificate available alert on outline (#144) 2020-08-03 13:04:47 -04:00
Renovate Bot
9accacd019 chore(deps): update dependency @testing-library/dom to v7.16.3 2020-08-03 16:23:38 +00:00
Renovate Bot
a9cec0102b chore(deps): pin dependencies 2020-08-03 10:16:24 -04:00
Michael Terry
e4d6d37c4e AA-124: Show course end alert (#135)
This is a parity-with-LMS change, to bring their warning about
the course ending within a couple weeks to the MFE.
2020-08-03 09:53:40 -04:00
edX Transifex Bot
7ad501d73b fix(i18n): update translations 2020-08-02 17:07:56 -04:00
David Joy
f1d43b18d6 Tweak CoursewareContainer tests to get them working again.
This is effectively fixing merge conflicts between:

https://github.com/edx/frontend-app-learning/pull/128

and:

https://github.com/edx/frontend-app-learning/pull/97
2020-07-31 16:02:38 -04:00
Agrendalath
d408986682 [TNL-7268] Fix tests after rebase 2020-07-31 16:02:38 -04:00
Agrendalath
8d7fbb5bd8 [TNL-7268] refactor tests to use factories 2020-07-31 16:02:38 -04:00
Agrendalath
7c046870e3 [TNL-7268] Remove icon mock
As we're not using snapshots, we will not need this anymore.
2020-07-31 16:02:38 -04:00
Agrendalath
0c5fd44d13 [TNL-7268] Fix tests after rebase 2020-07-31 16:02:38 -04:00
Agrendalath
f3d23abe84 [TNL-7268] Fix imports, ignore implicit exports for tests 2020-07-31 16:02:38 -04:00
Agrendalath
6a44d018d8 [TNL-7268] Remove unused dependency 2020-07-31 16:02:38 -04:00
Agrendalath
3362047bcc [TNL-7268] Replace snapshots with specific assertions 2020-07-31 16:02:38 -04:00
Agrendalath
c1bf77efa4 [TNL-7268] Fix tests after rebase 2020-07-31 16:02:38 -04:00
Agrendalath
e6443ae3bd [TNL-7268] Address the review comments 2020-07-31 16:02:38 -04:00
Agrendalath
6f7ec81197 [TNL-7268] Fix tests and dependencies after rebase 2020-07-31 16:02:38 -04:00
Agrendalath
8719fad091 [TNL-7268] Add high priority tests 2020-07-31 16:02:38 -04:00
Agrendalath
ec7f532bc9 [TNL-7268] WIP: Add high priority tests 2020-07-31 16:02:38 -04:00
Dillon Dumesnil
0c8389e244 AA-280: Add assignment type to dates tab display (#137) 2020-07-31 12:09:47 -07:00
Michael Terry
cd8f3072e2 AA-124: Refactor enrollment alerts (#126)
- Place them only on the Outline page
- Support a few cases where enrollment isn't actually allowed
2020-07-30 12:51:17 -04:00
David Joy
b048ca8187 Fixes saving unit position and unit redirection bugs (#128)
* Bumping axios-mock-adapter version

Thought there was a feature in 1.18.2 that I needed - turns out the feature hasn’t been released yet.  Still fine to bump the dependency, though.

* Hiding some warnings about console logging.

* Fixes bugs in CoursewareContainer

Fixes a few bugs in the courseware container:

- Position was not being saved because we weren’t reading “saveUnitPosition” correctly.
- We weren’t calling checkContentRedirect with the right arguments - it was using a non-existent unitId instead of the routeUnitId, meaning we would redirect to the active unit even if a unit was specified in the URL.

Adds tests in CoursewareContainer for various URL and data states.

Now explicitly tests:
- Exam redirects
- The resume block method when it has, and doesn’t have, a block to resume.
- The content redirect when a unit isn’t present on the URL (uses sequence.position)
- Loading a specific unit (not the first of a sequence!) by URL.

Updated some of the factories to be more flexible/allow multiple units.
2020-07-29 14:24:39 -04:00
daphneli-chen
71482f1ec7 AA-205: created chapters and subsections containing progress (#109)
Co-authored-by: Daphne Li-Chen <dli-chen@edx.org>
2020-07-28 12:53:49 -04:00
Nick
7a108728c0 AA-214 social buttons (#127)
- social sharing buttons for linkedin and email
2020-07-28 12:00:26 -04:00
Michael Terry
d7f41fd02a Enable handouts on outline page (#125) 2020-07-27 10:01:18 -04:00
edX Transifex Bot
0e7bccef0b fix(i18n): update translations 2020-07-26 17:07:31 -04:00
Michael Terry
5ac49610da Re-enable the celebration modal (#123)
It got accidentally disabled during a refactor.

Also, try a little harder to make sure it doesn't re-appear during
the same browsing session.
2020-07-24 13:02:50 -04:00
Carla Duarte
175675da55 AA-127: Fix outline tab redirect URL (#122)
Co-authored-by: Carla Duarte <cduarte@edx.org>
2020-07-23 16:01:46 -04:00
Nick
f715fd5ed6 AA-123 welcome message (#121) 2020-07-23 12:28:56 -04:00
Carla Duarte
cdab8959ca AA-218: Course Tool Analytics (#118)
Tracking analytics for onClick events in the Course Tool widget.
Extra: Fixed intl error in the Course Dates widget.

Co-authored-by: Carla Duarte <cduarte@edx.org>
2020-07-23 10:40:52 -04:00
David Joy
be0ee18519 fix: Use reselect’s defaultMemoize instead of lodash.memoize (#120)
* fix: Use reselect’s defaultMemoize instead of lodash.memoize

Lodash memoize doesn’t examine all parameters when deciding to memoize, apparently, meaning it doesn’t re-call the function if any parameter except the first changes.

More here: https://dev.to/nioufe/you-should-not-use-lodash-for-memoization-3441

* Fixing test setup.  Improper use of sequenceMetadata factory!

Two problems:

One, we weren’t properly passing the courseId into our sequenceMetadata factories, so it was differing than the one defined in courseMetadata.  This didn’t manifest until now because we weren’t using the one from sequenceMetadata until this memoization fix!

Two, I’d updated the options for sequenceMetadata to have a “unitBlocks” option, but didn’t update all the usages of the old “unitBlock” option.  This meant it was manually creating its own unit instead of inheriting the one from courseBlocks, resulting in a different ID.
2020-07-22 10:19:53 -04:00
David Joy
c96cd87967 Change CoursewareContainer into a class component. (#115)
I find it much more legible this way.

Some thoughts… as part of refactoring it, I made some of the redux selectors more formal, and made use of reselect more thoroughly. this resulted in a reduction in re-renders from 16 to 12 on your average page load. It’s also a bit more verbose, accounting for some of the increased line count.

I hadn’t tried it before, but found the memoize method of comparing previous props/state to current props/state to be very, very nice. Much easier than manually comparing props, and much clearer to me than using react hooks’ dependency arrays.

The lack of dependency arrays feels really freeing in general to me. They’ve been such a source of hard-to-track-down bugs, and the hooks linter does not always suggest the right solution for what belongs in and out of the array.

Function names are nice. We had a ton of custom hooks in there so that we could put names to otherwise anonymous bits of functionality.

Also note: this component has a test suite. It passed without any changes. 🥳
2020-07-21 09:31:12 -04:00
Calen Pennington
854020dd67 Fix test failures due to changed snapshots, and add representative data for extra_info 2020-07-20 15:45:49 -04:00
Calen Pennington
d320c6b5bc Include assignment extraInfo on dates page 2020-07-20 14:01:02 -04:00
Carla Duarte
81c6b401fd AA-230: Dates Tab MFE styling (#111)
Co-authored-by: Carla Duarte <cduarte@edx.org>
2020-07-20 13:39:56 -04:00
Michael Terry
16bd20e0e8 AA-121: Support showing handouts in the course outline (#112)
And add translation support for the outline.
2020-07-20 09:24:26 -04:00
edX Transifex Bot
23ea255674 fix(i18n): update translations 2020-07-19 17:07:19 -04:00
edX Transifex Bot
dec5340bf3 fix(i18n): update translations 2020-07-17 15:23:15 -04:00
Patrick Cockwell
086b5d8986 TNL-7303 Add Course License details (#103) 2020-07-17 13:26:21 -04:00
David Joy
b940901400 fix: Use activeUnitIndex instead of position, and remove the latter (#110)
We were inconsistently using “position” - a 1-indexed value - in JS arrays which are 0-indexed.  We had an existing, normalized property called “activeUnitIndex” which we now use everywhere instead.  The value is modified back to 1-indexed before being returned to the server.
2020-07-16 10:14:18 -04:00
David Joy
afb4b77250 CoursewareContainer tests. (#108)
* Adding testing-library dependencies, and bumping frontend-build to be compatible with them.

* Adding a function to initialize the redux store

We need to use it in a few places.  Seems worth not-repeating, since they can easily get out of sync.  In general, tests should only test the parts of the store they care about, as well.

* Adding function to initialize a mock application.

Ultimately I’d like to move this to frontend-platform as an alternative to ‘initialize’ for tests.  ‘initialize’ is an async function which complicates matters.

* Using more explicit assertions for courseware reducer fields.

This removes the need for the snapshot file, and ensures our test is more resilient to unrelated changes in the store.

Also added a few more stages of assertions to some of the tests, showing that they have the right values over time.

* Adding a helper to build a simple course blocks response.

We can use this in the courseware data tests, and shortly in the tests for CoursewareContainer.

* Modifying sequenceMetadata factory to allow multiple units.

This will help us test sequence navigation’s behavior more fully by having multiple units in a sequence.

* A little linting and cleanup.

* Adding first round of tests for CoursewareContainer.

Tests loading, sequence navigation/unit rendering, and ‘denied’ states.

Subsequent tests will add tests for handlers.
2020-07-15 10:27:48 -04:00
Carla Duarte
bc30b20b0d AA-221: Pull in has_ended variable for dates banner logic (#107)
Co-authored-by: Carla Duarte <cduarte@edx.org>
2020-07-14 15:58:48 -04:00
Bill Currie
3e14b17271 [BD-29] [TNL-7288] Fix front-end behavior when the course has no sections or no subsections in the first section (#98)
* Do not redirect when the sequenceId is not valid

That is, if firstSequenceId is null or undefined. This prevents the url
becoming bogus but does cause the course contents display to become stuck
with the loading message.

* Detect invalid sequence when loading

If the course has no sections or the first section has no sub-sections,
then sequence will be null. Before the redirection fix, this would cause an
error, but after, the sequenceStatus never leaves the loading state. Thus,
if still loading and the sequence is null, return the no.content message.

* Check sequenceId instead of sequence

From David Joy:

During initial page load, I expect there's a period of time before the
course blocks API and the sequence metadata API come back where the
sequenceStatus is loading but the sequence is still false, meaning that
we'll see a flash of this 'no content' messaging for a moment before the
data comes in.

If we instead check whether sequenceId is null here, that may give us a
more accurate condition. The sequenceId in redux is only populated when we
begin to request a sequence (fetchSequence thunk). If we have no sequence
ID in the URL route, then fetchSequence never happens and the sequenceId in
redux stays null.

* Fix up some additional errors Piotr found

This fixes errors caused by deleting units or subsections.

* Move test for unit validity to SequenceContent
2020-07-14 15:21:33 -04:00
Kyle McCormick
c0d0895630 Do not assume HTML blocks are units (#105)
After further conversation, we decide that
we shouldn't just treat HTML blocks as units.
We've been making the assumption that Verticals
occupy the Unit-level, and we shouldn't break that
assumption for this specific case.

Reverts part of e04f588d1f.
However, the error handling changes remain.
2020-07-10 09:05:04 -04:00
Michael Terry
c2f4ba3ad0 AA-195: Don't show schedule banner if no schedule (#100)
In some cases (user schedule is close to or past the course end),
we don't actually have dates for a course, even if we would normally
have them.

When that happens, let's not show a banner saying we made a
schedule for the learner.
2020-07-09 16:10:49 -04:00
Demid
eac9bf9c92 [BD-29] [TNL-7266] Learning MFE data integration tests (#95)
* Add test for fetchCourse

* Add tests for fetchDatesTab, fetchOutlineTab, fetchSequence and resetDeadlines

* Implement fetch tabs tests

* Add fail test case for fetchSequence

* Add success test for fetchSequence

* Add test for resetDeadlines

* Update test group name

* Add empty tests for courseware and bookmarks

* Fix wrong field in saveSequencePosition thunk

* Add tests for courseware data layer

* Temporary commit

* Split tests after rebase

* Revert "Fix wrong field in saveSequencePosition thunk"

This reverts commit 4394d363c58ad929f81e587ce4da2241528494b5.

* Fix test for position

* Move executeThunk into utils

* Add test for all reducers

* Add expect statements for logs

* Remove redundant snapshot tests and add some specific checks

* Polishing

* Remove redundant checks

* Fix bug in normalizer and update test

* Upgrade @edx/frontend-platform dependency

* Utilize MockAuthService instead of manual auth package mocking

* Update tests after breaking changes in master

* Remove redundant snapshot check
2020-07-09 10:39:37 -04:00
David Joy
5332be8e65 Update README.rst 2020-07-09 10:28:57 -04:00
Kyle McCormick
e04f588d1f Treat html-type XBlocks as units (#104)
Fixes bug where courses with html-type XBlocks as children
of sequences would ignore those children instead of treating them
as units. This caused the app to later just give up and redirect
to the course home in the old experience.

Also, handle that scenario where we have sections/sequences
with children of unexpected block types more gracefully by
logging an error instead of crashing.

TNL-7305
2020-07-09 10:22:30 -04:00
daphneli-chen
fe013f57c5 AA-203: Created progress page and receiving username, email, and enrollment status (#96)
Co-authored-by: Daphne Li-Chen <dli-chen@edx.org>
2020-07-08 16:00:25 -04:00
David Joy
7df50264cf Update 0004-model-store.md (#101)
Explaining the rationale for model-store in a bit more detail.
2020-07-02 13:24:25 -04:00
David Joy
73c74119f0 Organizationing (#102)
* Moving model-store into “generic” sub-directory.

Also adding a README.md to explain what belongs in “generic”

* Moving user-messages into “generic” sub-directory.

* Moving PageLoading into “generic” sub-directory.

* Moving “tabs” module into “generic” sub-directory.

* Moving InstructorToolbar and MasqueradeWidget up to instructor-toolbar.

The masquerade widget is a sub-module of instructor-toolbar.

* Co-locating celebration APIs with celebration utils.

Also adding an ADR about thunk/API naming conventions and making some other areas of the code adhere to it.

* Moving courseware data (thunks, api) into the courseware module.

Note that cousre-home/data/api still uses normalizeBlocks - this should be fixed so it’s not reaching across.  Maybe we pull that particular API up top.

This PR includes a few TODOs for things I saw, as well as a tiny bit of whitespace cleanup.
2020-07-02 13:11:50 -04:00
Carla Duarte
a6edc9132f AA-186: Refactoring to separate Course Home logic from Courseware (#93)
- Pulled Course Home specific components into `course-home`
- Created a courseHome reducer (and all necessary data files - api, thunks, slice)
- Removed Course Home logic from Courseware's data files (api, thunks, slice, etc.)
- Renamed Outline Tab URL to end in `/home` rather than `/outline` again (per Product)

Co-authored-by: Carla Duarte <cduarte@edx.org>
2020-06-25 10:26:47 -04:00
Patrick Cockwell
8b34f8c792 TNL-7126 BD-29 Visual Updates to Learning Sequence (#88)
* BB-2569: Use faVideo instead of faFilm for video units; Set page title based on section, sequence, and course titles

* Add CourseLicense component with styling

* Reorder the pageTitleBreadCrumbs that are used for setting the page title

* Revert "Add CourseLicense component with styling"

This reverts commit 8d154998de.

* Fix package-lock.json so that only new changes for react-helmet are included
2020-06-23 16:09:12 -04:00
daphneli-chen
534b9b205f AA-122: Created Dates Widget on course home page (#82)
Including upcoming dates and a link to dates tab. Gives user ability to
look at any important upcoming dates for their course and to navigate
to upcoming assignments.

Co-authored-by: Daphne Li-Chen <daphneli-chen@MacBook-Pro.local>
2020-06-23 14:05:18 -04:00
Michael Terry
1471abe7dd dates: fix missing Today entry if it would be last entry (#94) 2020-06-23 09:39:54 -04:00
Michael Terry
d55c8b134c Don't show verified-only badge on Today (#92)
If the Today date entry on the dates tab doesn't have any items,
we were showing the Verified Only badge. This fixes that mistake.
2020-06-22 14:37:03 -04:00
Michael Terry
6c052a2661 Avoid redundant paragon sass import (#91)
Move all scss imports into the main index.scss. This will let them
all know about existing sass variables and avoid duplicate imports.
2020-06-22 11:24:15 -04:00
Michael Terry
ac47454b14 Don't show celebration modal too much (#89)
Make sure we only show it for the first unit of a sequence.
2020-06-19 14:29:13 -04:00
stvn
a18adc4112 Merge PR #71 add/masquerade
* Commits:
  Show audit content lock when masquerading
  Create masquerade widget component
2020-06-19 09:59:38 -07:00
Michael Terry
6cdd075243 AA-137: Add first-section celebration (#78)
When a learner completes their first section in a course, throw up
a modal that celebrates that fact and encourages them to share
progress.
2020-06-18 09:27:11 -04:00
David Joy
eb8c97ee86 fix: Ensure lmsWebUrl is loaded in useExamRedirect (#87)
The sequence.lmsWebUrl variable is loaded as part of the course blocks API.  The status of that API’s request is stored in courseStatus.

The useEffect hook in useExamRedirect didn’t ensure that courseStatus was equal to “loaded”.  This meant that if the sequence loaded first, it might attempt to redirect to sequence.lmsWebUrl even though that variable is still undefined.

When global.location.assign() is given `undefined` as a value, it tacks it onto the end of the URL and calls it a day.  After that, we’ve got a badly formed URL.
2020-06-17 14:07:14 -04:00
stvn
24051232af Show audit content lock when masquerading 2020-06-16 23:59:30 -07:00
stvn
dee5128448 Create masquerade widget component
on the Staff Instructor Toolbar
2020-06-16 23:59:30 -07:00
Nick
5ffc1bc599 Add logging back to the coursemetadata call (#86) 2020-06-16 14:42:18 -04:00
Nick
bf0d3b1565 AA-133 mfe dates banner fix (#85)
- mfe dates banner along with fixing previously reverted
  PR by adding back in original coursemetadata call.

This reverts commit 8df4654cf1.
2020-06-16 13:11:50 -04:00
Nick
8df4654cf1 Revert "AA-133 mfe dates banner (#79)" (#84)
This reverts commit 973f3d68aa.
2020-06-16 10:14:30 -04:00
Nick
973f3d68aa AA-133 mfe dates banner (#79)
- dates banner on dates tab
2020-06-16 09:55:04 -04:00
Carla Duarte
b51809fa50 Quick fix: Corrected Outline Tab redirect URL (#83)
Co-authored-by: Carla Duarte <cduarte@edx.org>
2020-06-15 17:54:16 -04:00
Carla Duarte
253836fa9f AA-181: Outline Tab Refactor (#80)
- Updated the Outline Tab to fetch course blocks from the Outline API.
- Changed naming conventions to more accurately portray the tab naming scheme
(ex. Outline Tab, Dates Tab, etc.)
- Removed logic from `fetchCourses` that was specific to the Outline Tab
2020-06-15 15:19:13 -04:00
David Joy
65173e9f93 fix: remove conditions for an infinite React hooks loop (#81)
The useAlert hook was being given a new payload object every time it was called, defeating any memoization happening inside.

It was also re-calling it’s useEffect hook when alertId changed, which it was changing itself. That’s a no-no.
2020-06-10 17:08:07 -04:00
David Joy
a8d01c423d Miscellaneous small refactorings (#74)
* Normalizing “courseInfo” back into “course”

Splitting it out denormalizes the data and introduces potential data inconsistencies.

* Name component JSX files with the name of the component.

* Normalizing some module exports/naming.

* Moving alerts into a sub-directory.

* DRYing up alert hook creation into reusable useAlert hook.

* Adding some comments about ‘courses’ hydration.
2020-06-02 14:15:12 -04:00
Carla Duarte
025f37cd21 AA-120: Course Tools Widget (#73)
Co-authored-by: Carla Duarte <cduarte@edx.org>
2020-06-02 13:41:23 -04:00
Michael Terry
964bde180a Further UI tweaks to the Dates tab (#76)
Allow for per-item badges if not all items in a given day match
for the badge. And some minor spacing changes.
2020-06-01 09:45:35 -04:00
Nick
209a64c29b AA-117 mfe dates tab waffle flag (#75)
- redirect back to platform dates tab if waffle flags are not
  enable for the mfe dates tab
2020-05-29 13:48:23 -04:00
Michael Terry
bdb1afe990 Finish up basic dates tab support (#70)
- Drop mock data, call real API instead
- Call course metadata API for general info, not the dates API
- Mark text as translatable
- Add badges and timeline dots, group same-day items

AA-116
2020-05-26 13:15:36 -04:00
Michael Terry
7487d8d32f Add basic dates tab (#62)
This is not the final visuals for the dates tab, but an in-progress
page to base further work on.

AA-116
2020-05-26 13:07:24 -04:00
Michael Terry
e101b41c08 Whoops, add one-liner missing from github in last commit (#72) 2020-05-21 12:03:09 -04:00
Michael Terry
2f01e8a646 Refactor containers to share more code (#61)
Specifically, make sure that the header, footer, and tabs are all
shared code so that they look the same and don't need to be
redefined as we add more tab pages.
2020-05-21 11:56:49 -04:00
stvn
589db9356e Merge PR #65 add/staff-links
* Commits:
  Remove explanatory paragraph
  Add new Studio/insights links to InstructorToolbar
  Add courseId to InstructorToolbar props
  Create new config values for Insights/Studio URLs
  Fix missing definition of unitId in InstructorToolbar.props
  Set NODE_ENV in the test environment
  Fix mismatched test BASE_URL
  Cleanup PORT config
2020-05-15 14:25:10 -07:00
stvn
e2f37ff20e Remove explanatory paragraph 2020-05-15 12:36:25 -07:00
stvn
ab544b5d2b Add new Studio/insights links to InstructorToolbar 2020-05-15 12:36:25 -07:00
stvn
7bef14c329 Add courseId to InstructorToolbar props 2020-05-15 12:36:25 -07:00
stvn
5cb11189a7 Create new config values for Insights/Studio URLs 2020-05-15 12:36:23 -07:00
stvn
fd951fb18a Fix missing definition of unitId in InstructorToolbar.props 2020-05-15 12:05:08 -07:00
stvn
b2fa93af13 Set NODE_ENV in the test environment 2020-05-15 12:04:34 -07:00
stvn
93ccdf829b Fix mismatched test BASE_URL 2020-05-15 12:04:34 -07:00
stvn
65ab77bed3 Cleanup PORT config 2020-05-15 12:04:34 -07:00
stvn
efba1c1f5a Merge PR #67 toggle/course-sock
* Commits:
  Show course-sock only when the API says so
2020-05-15 09:37:16 -07:00
Ned Batchelder
3c53c4af4e Mark this repo for inclusion in Open edX release tagging (#68) 2020-05-15 10:08:07 -04:00
Dave St.Germain
2b27f0774d Resume from last completed unit (#66) 2020-05-14 11:17:35 -04:00
stvn
97f335be62 Show course-sock only when the API says so 2020-05-12 12:20:29 -07:00
stvn
6c7af3817b Merge PR #64 fix/banner/expiration
* Commits:
  Make the two rawHtml alerts look near-identical
2020-05-07 12:07:00 -07:00
stvn
a7932ed730 Make the two rawHtml alerts look near-identical 2020-05-07 11:57:35 -07:00
David Joy
29b234e2f0 Scroll to top when the sequenceId or unitId changes (#63)
* Scroll to top when the sequenceId or unitId changes

* Add a spinner to the unit.
2020-05-06 12:59:17 -04:00
David Joy
a718c67f36 Show message when there are no units in a sequence. (#60)
TNL-7191 - We didn’t fully protect against sequences with no units. The next/previous buttons now check whether there is a unit ID and construct a URL without if one doesn’t exist.  When we load a sequence without units, we now show a message to the user so the page doesn’t look broken.
2020-05-05 09:46:18 -04:00
stvn
cc5e5ecc00 Merge PR #55 refactor-iframe-messages
* Commits:
  Refactor iframe message handler
2020-05-04 21:30:03 -07:00
stvn
7df95378d6 Refactor iframe message handler
TNL-7187
2020-05-04 14:17:39 -07:00
Adam Butterworth
18426dd313 make unitNavigationHandler hook depend on unitId (#59)
This should fix intermittent bugs in checking block completions. Prior we were checking the completion only for the first unit loaded in a given sequence no matter the current unit.
2020-05-04 16:55:16 -04:00
Adam Butterworth
a1eee2d662 Fix IE11 layout issue by setting header flex-basis to auto (#58) 2020-05-04 16:14:20 -04:00
Adam Butterworth
7dfb01a397 Mobile fixes: content tools and verified certificate details (#57)
* Prevent wrapping of show notes button

* text overflow

* Update layout for course sock
2020-05-04 16:06:19 -04:00
David Joy
d58a81bf19 Use layout effect to avoid iframe pausing React lifecycle (#56)
Fixes TNL-7187 - Adds a no-op useLayoutEffect hook to Unit.jsx to prevent the unit iframe from pausing React’s rendering lifecycle.  Very strange bug - see comments in that file for more detail.
2020-05-04 12:37:23 -04:00
stvn
bd0ab5b6c9 Merge PR #54 debug/iframe
* Commits:
  Add temporary logging to iframe message handler
2020-04-30 11:46:04 -07:00
stvn
5185f986df Add temporary logging to iframe message handler 2020-04-30 11:43:57 -07:00
David Joy
d3b22bc879 TNL-7164, Enroll Now button fix, flash messages, and custom message props (#53)
* Adding an index.js file for user-messages.

Importing from the module, not its contents.

* Allowing customProps to be passed though AlertList to Alerts.

* UserMessagesProvider can create flash messages.

A flash message is one that will be displayed on the next reload of the page.  UserMessagesProvider now provides a “addFlash” function.  These messages are stored in localStorage and displayed the next time UserMessagesProvider is mounted, which is generally going to be on the next page refresh.

Once displayed, flash messages are cleared out of localStorage.

* Hooking up Enroll Now button and adding “success” alert.

Success alert is shown as a flash message on next page reload.

* Using ALERT_TYPES constants.
2020-04-30 10:22:44 -04:00
stvn
36526def67 Merge PR #52 upgrade offer banner
* Commits:
  Use new upgrade offer banner
  Add new upgrade offer alert
2020-04-23 10:48:58 -07:00
stvn
c510fe1c1d Use new upgrade offer banner 2020-04-23 10:28:03 -07:00
stvn
ca8afb3294 Add new upgrade offer alert 2020-04-23 10:28:00 -07:00
stvn
1f4e2cd6f5 Merge PR #50 banner/lock-access
* Commits:
  Add audit access locked banner
2020-04-21 12:48:56 -07:00
stvn
6d60584596 Add audit access locked banner
when Content Type Gating, aka Feature Based Enrollment is enabled.
2020-04-21 12:46:11 -07:00
stvn
b20a4ed304 Merge PR #49 banner/access-expiration
* Commits:
  Add warning banner for audit access expiration
2020-04-21 12:43:09 -07:00
stvn
44f535ba1e Add warning banner for audit access expiration
to inform users of deadline and prompt them to upgrade to the verified
access track.
2020-04-21 12:38:11 -07:00
David Joy
5f0774b66d Fixing logout URL in dev and test. 2020-04-21 11:26:10 -04:00
Adam Butterworth
04a8638d00 Reduce min-width of unit buttons in nav (#51)
Makes space for more units before swapping the display to a dropdown.
2020-04-16 10:37:29 -04:00
Adam Butterworth
1cc7dc266b Redirect users when they cannot access content (#48)
TNL-7171, TNL-7172, TNL-7173, TNL-7174: When a user is denied access to load courseware, redirect them to the appropriate location based upon the error code returned. If the error code is unknown they will be redirected to course home.
2020-04-15 12:56:51 -04:00
Adam Butterworth
a852182a00 Support can_load_courseware as legacy boolean and future object (#47) 2020-04-09 16:10:55 -04:00
Dave St.Germain
15c3053e87 Adds notes visibility toggle (#44)
* added notes

* moved around components

* Addressed feedback
2020-04-09 14:46:33 -04:00
David Joy
e2399e30d4 fix: Pull lms_base_url out of vertical data from the blocks API (#46)
TNL-7170

lms_base_url becomes lmsBaseUrl in the app and is then used by the InstructorToolbar to link the user back to the LMS.  If it isn’t present, the toolbar hides itself.  This puts it back.
2020-04-09 11:26:02 -04:00
443 changed files with 82231 additions and 14067 deletions

59
.env
View File

@@ -1,16 +1,45 @@
# See README.rst for explanations of these.
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=null
BASE_URL=null
CREDENTIALS_BASE_URL=null
CSRF_TOKEN_API_PATH=null
ECOMMERCE_BASE_URL=null
LANGUAGE_PREFERENCE_COOKIE_NAME=null
LMS_BASE_URL=null
LOGIN_URL=null
LOGOUT_URL=null
MARKETING_SITE_BASE_URL=null
ORDER_HISTORY_URL=null
REFRESH_ACCESS_TOKEN_ENDPOINT=null
SEGMENT_KEY=null
SITE_NAME=null
USER_INFO_COOKIE_NAME=null
ACCESS_TOKEN_COOKIE_NAME=''
BASE_URL=''
CONTACT_URL=''
CREDENTIALS_BASE_URL=''
CREDIT_HELP_LINK_URL=''
CSRF_TOKEN_API_PATH=''
DISCOVERY_API_BASE_URL=''
DISCUSSIONS_MFE_BASE_URL=''
ECOMMERCE_BASE_URL=''
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
FAVICON_URL=''
IGNORED_ERROR_REGEX=''
INSIGHTS_BASE_URL=''
LANGUAGE_PREFERENCE_COOKIE_NAME=''
LMS_BASE_URL=''
LOGIN_URL=''
LOGOUT_URL=''
LOGO_URL=''
LOGO_TRADEMARK_URL=''
LOGO_WHITE_URL=''
LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL=''
ORDER_HISTORY_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEARCH_CATALOG_URL=''
SEGMENT_KEY=''
SESSION_COOKIE_DOMAIN=''
SITE_NAME=''
SOCIAL_UTM_MILESTONE_CAMPAIGN=''
STUDIO_BASE_URL=''
SUPPORT_URL=''
SUPPORT_URL_CALCULATOR_MATH=''
SUPPORT_URL_ID_VERIFICATION=''
SUPPORT_URL_VERIFIED_CERTIFICATE=''
TERMS_OF_SERVICE_URL=''
TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''

View File

@@ -1,17 +1,45 @@
# See README.rst for explanations of these.
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='development'
PORT=2000
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:2000'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='localhost:1996/orders'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=''
SITE_NAME='edX'
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
STUDIO_BASE_URL='http://localhost:18010'
SUPPORT_URL='https://support.edx.org'
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost'

View File

@@ -1,15 +1,44 @@
# See README.rst for explanations of these.
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:1995'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='localhost:1996/orders'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=''
SITE_NAME='edX'
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
STUDIO_BASE_URL='http://localhost:18010'
SUPPORT_URL='https://support.edx.org'
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'

View File

@@ -1,4 +1,5 @@
coverage/*
dist/
packages/
node_modules/
jest.config.js
jest.config.js

View File

@@ -1,3 +1,11 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('eslint');
module.exports = createConfig('eslint', {
overrides: [{
files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)", "setupTest.js"],
rules: {
'import/named': 'off',
'import/no-extraneous-dependencies': 'off',
},
}],
});

View File

@@ -0,0 +1,19 @@
# Run the workflow that adds new tickets that are either:
# - labelled "DEPR"
# - title starts with "[DEPR]"
# - body starts with "Proposal Date" (this is the first template field)
# to the org-wide DEPR project board
name: Add newly created DEPR issues to the DEPR project board
on:
issues:
types: [opened]
jobs:
routeissue:
uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

10
.github/workflows/commitlint.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
# Run commitlint on the commit messages in a pull request.
name: Lint Commit Messages
on:
- pull_request
jobs:
commitlint:
uses: edx/.github/.github/workflows/commitlint.yml@master

24
.github/workflows/validate.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: validate
on:
push:
branches:
- 'master'
pull_request:
branches:
- '**'
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [12, 14, 16]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- run: make validate.ci
- name: Upload coverage
uses: codecov/codecov-action@v2
with:
fail_ci_if_error: true

9
.gitignore vendored
View File

@@ -8,11 +8,18 @@ coverage
dist/
src/i18n/transifex_input.json
temp/babel-plugin-react-intl
logs
### pyenv ###
.python-version
### Emacs ###
### Editors ###
*~
/temp
/.vscode
# Local package dependencies
module.config.js
# Local environment overrides
.env.private

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint

View File

@@ -1,15 +0,0 @@
language: node_js
node_js: 12
before_install:
- npm install -g npm@6
install:
- npm ci
script:
- make validate-no-uncommitted-package-lock-changes
- npm run i18n_extract
- npm run lint
- npm run test
- npm run build
- npm run is-es5
after_success:
- codecov

View File

@@ -1,8 +1,9 @@
[main]
host = https://www.transifex.com
[edx-platform.frontend-app-learning]
[o:open-edx:p:edx-platform:r:frontend-app-learning]
file_filter = src/i18n/messages/<lang>.json
source_file = src/i18n/transifex_input.json
source_lang = en
type = KEYVALUEJSON
type = KEYVALUEJSON

0
LICENSE Executable file → Normal file
View File

26
Makefile Executable file → Normal file
View File

@@ -1,11 +1,9 @@
transifex_resource = frontend-app-learning
export TRANSIFEX_RESOURCE=frontend-app-learning
transifex_langs = "ar,fr,es_419,zh_CN"
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
@@ -38,17 +36,31 @@ push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
# Pushing comments to Transifex...
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --language=$(transifex_langs)
tx pull -f --mode reviewed --languages=$(transifex_langs)
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:
# Checking for package-lock.json changes...
git diff --exit-code package-lock.json
.PHONY: validate
validate:
make validate-no-uncommitted-package-lock-changes
npm run i18n_extract
npm run lint -- --max-warnings 0
npm run test
npm run build
npm run is-es5
.PHONY: validate.ci
validate.ci:
npm ci
make validate

View File

@@ -1,22 +1,121 @@
|Build Status| |Coveralls| |npm_version| |npm_downloads| |license|
|codecov| |license|
frontend-app-learning
=========================
Please tag **@edx/teaching-and-learning** on any PRs or issues. Thanks.
Introduction
------------
React app for edX learning.
This is the Learning MFE (micro-frontend application), which renders all
learner-facing course pages (like the course outline, the progress page,
actual course content, etc).
Please tag **@edx/engage-squad** on any PRs or issues. Thanks.
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
:target: https://codecov.io/gh/edx/frontend-app-learning
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
:target: https://github.com/edx/frontend-app-account/blob/master/LICENSE
Development
-----------
Start Devstack
^^^^^^^^^^^^^^
To use this application, `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
- Run ``make dev.up.lms``
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
Local module development
^^^^^^^^^^^^^^^^^^^^^^^^
To develop locally on modules that are installed into this app, you'll need to create a ``module.config.js``
file (which is git-ignored) that defines where to find your local modules, for instance::
module.exports = {
/*
Modules you want to use from local source code. Adding a module here means that when this app
runs its build, it'll resolve the source from peer directories of this app.
moduleName: the name you use to import code from the module.
dir: The relative path to the module's source code.
dist: The sub-directory of the source code where it puts its build artifact. Often "dist", though you
may want to use "src" if the module installs React as a peer/dev dependency.
*/
localModules: [
{ moduleName: '@edx/paragon/scss', dir: '../paragon', dist: 'scss' },
{ moduleName: '@edx/paragon', dir: '../paragon', dist: 'dist' },
{ moduleName: '@edx/frontend-enterprise', dir: '../frontend-enterprise', dist: 'src' },
{ moduleName: '@edx/frontend-platform', dir: '../frontend-platform', dist: 'dist' },
],
};
See https://github.com/edx/frontend-build#local-module-configuration-for-webpack for more details.
Deployment
----------
The Learning MFE is similar to all the other Open edX MFEs. Read the Open
edX Developer Guide's section on
`MFE applications <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html>`_.
Environment Variables
^^^^^^^^^^^^^^^^^^^^^
This MFE is configured via environment variables supplied at build time.
All micro-frontends have a shared set of required environment variables,
as documented in the Open edX Developer Guide under
`Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`_.
The learning micro-frontend also supports the following additional variables:
CREDIT_HELP_LINK_URL
A link to resources to help explain what course credit is and how to earn it.
ENABLE_JUMPNAV
Enables the new Jump Navigation feature in the course breadcrumbs, defaulted to the string 'true'.
Disable to have simple hyperlinks for breadcrumbs. Setting it to any other value but 'true' ('false','I love flags', 'etc' would disable the Jumpnav).
This feature flag is slated to be removed as jumpnav becomes default. Follow the progress of this ticket here:
https://openedx.atlassian.net/browse/TNL-8678
SOCIAL_UTM_MILESTONE_CAMPAIGN
This value is passed as the ``utm_campaign`` parameter for social-share
links when celebrating learning milestones in the course. Optional.
Example: ``milestone``
SUPPORT_URL_CALCULATOR_MATH
A link that explains how to use the in-course calculator. You can use the
one in the example below, if you don't want to have your own branded version.
Example: https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator
SUPPORT_URL_ID_VERIFICATION
A link that explains how to verify your ID. Shown in contexts where you need
to verify yourself to earn a certificate. The example link below is probably too
edx.org-specific to use for your own site.
Example: https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity
SUPPORT_URL_VERIFIED_CERTIFICATE
A link that explains what a verified certificate is. You can use the
one in the example below, if you don't want to have your own branded version.
Optional.
Example: https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate
TWITTER_HASHTAG
This value is used in the Twitter social-share link when celebrating learning
milestones in the course. Will prefill the suggested post with this hashtag.
Optional.
Example: ``brandedhashtag``
TWITTER_URL
A link to your Twitter account. The Twitter social-share link won't appear
unless this is set. Optional.
Example: https://twitter.com/edXOnline
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-learning.svg?branch=master
:target: https://travis-ci.org/edx/frontend-app-learning
.. |Coveralls| image:: https://img.shields.io/coveralls/edx/frontend-app-learning.svg?branch=master
:target: https://coveralls.io/github/edx/frontend-app-learning
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-learning.svg
:target: @edx/frontend-app-learning
.. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-learning.svg
:target: @edx/frontend-app-learning
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-learning.svg
:target: @edx/frontend-app-learning

View File

@@ -1,5 +1,7 @@
# Courseware Page Decisions
**See [0009-courseware-api-direction.md](0009-courseware-api-direction.md) for updates!**
## Courseware data loading
Today we have strictly hierarchical courses - a course contains sections, which contain sequences, which contain units, which contain components.
@@ -37,6 +39,9 @@ Today, if the URL only specifies the course ID, we need to pick a sequence to sh
Similarly, if the URL doesn't contain a unit ID, we use the `position` field of the sequence to determine which unit we want to display from that sequence. If the position isn't specified in the sequence, we choose the first unit of the sequence. After determining which unit to display, we update the URL to match. After the URL is updated, the application will attempt to load that unit via an iFrame.
_This URL scheme has been expanded upon in
[ADR #8: Liberal courseware path handling](./0008-liberal-courseware-path-handling.md)._
## "Container" components vs. display components
This application makes use of a few "container" components at the top level - CoursewareContainer and CourseHomeContainer.

View File

@@ -4,4 +4,6 @@ Because we have a variety of models in this app (course, section, sequence, unit
https://redux.js.org/faq/organizing-state#how-do-i-organize-nested-or-duplicate-data-in-my-state
(As an additional data point, djoy has stored data in this format in multiple projects over the years and found it to be very effective)
Different modules of the application maintain individual/lists of IDs that reference data stored in the model store. These are akin to indices in a database, in that they allow you to quickly extract data from the model store without iteration or filtering.
A common pattern when loading data from an API endpoint is to use the model-store's redux actions (addModel, updateModel, etc.) to load the "models" themselves into the model store by ID, and then dispatch another action to save references elsewhere in the redux store to the data that was just added. When adding courses, sequences, etc., to model-store, we also save the courseId and sequenceId in the 'courseware' part of redux. This means the courseware React Components can extract the data from the model-store quickly by using the courseId as a key: `state.models.courses[state.courseware.courseId]`. For an array, it iterates once over the ID list in order to extract the models from model-store. This iteration is done when React components' re-render, and can be done less often through memoization as necessary.

View File

@@ -0,0 +1,24 @@
# Naming API functions and redux thunks
Because API functions and redux thunks are two parts of a larger process, we've informally settled on some naming conventions for them to help differentiate the type of code we're looking at.
## API Functions
This micro-frontend follows a pattern of naming API functions with a prefix for their HTTP verb.
Examples:
`getCourseBlocks` - The GET request we make to load course blocks data.
`postSequencePosition` - The POST request for saving sequence position.
## Redux Thunks
Meanwhile, we use a different set of verbs for redux thunks to differentiate them from the API functions. For instance, we use the `fetch` prefix for loading data (primarily via GET requests), and `save` for sending data back to the server (primarily via POST or PATCH requests)
Examples:
`fetchCourse` - The thunk for getting course data across several APIs.
`fetchSequence` - The thunk for the process of retrieving sequence data.
`saveSequencePosition` - Wraps the POST request for sending sequence position back to the server.
The verb prefixes for thunks aren't perfect - but they're a little more 'friendly' and semantically meaningful than the HTTP verbs used for APIs. So far we have `fetch`, `save`, `check`, `reset`, etc.

View File

@@ -0,0 +1,66 @@
# Testing
## Status
Draft
Let's live with this a bit longer before deciding it's a solid approach and marking this Approved.
## Context
We'd like to all be on the same page about how to approach testing, what is
worth testing, and how to do it.
## React Testing Library
We'll use react-testing-library and jest as the main testing tools.
This has some implications about how to test. You can read the React Testing Library's
[Guiding Principles](https://testing-library.com/docs/guiding-principles), but the main
takeaway is that you should be interacting with React as closely as possible to the way
the user will interact with it.
For example, they discourage using class or element name selectors to find components
during a test. Instead, you should find them by user-oriented attributes like labels,
text, or roles. As a last resort, by a `data-testid` tag.
## Mocking data
We'll use [Rosie](https://github.com/rosiejs/rosie) as a tool for building JavaScript objects.
Our main use case for Rosie is to use factories in order to mock the data we'd like to fetch when rendering components.
[axios-mock-adapter](https://www.npmjs.com/package/axios-mock-adapter) allows us to mock the response of an HTTP request.
For example, we may use a factory to build a course metadata object:
`const courseMetadata = Factory.build('courseMetadata');`
Then we'd pass that `courseMetadata` object into an axios mock call:
`axiosMock.onGet('example.com').reply(200, courseMetadata);`
This way, when a component sends a GET request to `example.com` within the test's lifecycle, the request will be intercepted
by the axios-mock-adapter, and the courseMetadata object will be returned.
These factories should live within the data directories they intend to mock
```
courseware
| data
| __factories__
| courseMetadata.factory.js /* used to define the Rosie factory */
| api.js /* getCourseMetadata() lives here */
```
## What to Test
We have not found exhaustive unit testing of frontend code to be worth the trouble.
Rather, let's focus on testing non-obvious behavior.
In essence: `test behavior that wouldn't present itself to a developer playing around`.
Practically speaking, this means error states, interactive components, corner cases,
or anything that wouldn't come up in a demo course. Something a developer wouldn't
notice in the normal course of working in devstack.
## Snapshots
In practice, we've found snapshots of component trees to be too brittle to be worth it,
as refactors occur or external libraries change.
They can still be useful for data (like redux tests) or tiny isolated components.
But please avoid for any "interesting" component. Prefer inspecting the explicit behavior
under test, rather than just snapshotting the entire component tree.

View File

@@ -0,0 +1,90 @@
# Liberal courseware path handling
## Status
Accepted
_This updates some of the content in [ADR #2: Courseware page decisions](./0002-courseware-page-decisions.md)._
## Context
The courseware container currently accepts three path forms:
1. `/course/:courseId`
2. `/course/:courseId/:sequenceId`
3. `/course/:courseId/:sequenceId/:unitId`
Forms #1 and #2 are always redirected to Form #3 via simple set of rules:
* If the sequenceId is not specified, choose the first sequence in the course.
* If the unitId is not specified, choose the active unit in the sequence,
or the first unit if none are active.
Thus, Form #3 is effectively the canonoical path;
all Learning MFE units should be served from it.
We acknowledge that the best user experience is to link directly to the canonoical
path when possible, since it skips the redirection steps.
Still, there are times when it is necessary or prudent to link just to a course or
a sequence.
Through recent work in the LMS, we are realizing that there are _also_ times where it
would be simpler or more performant to link a user to an
_entire section without specifying a squence_ or to a
_unit without including the sequence_.
Specifically, this capability would let as avoid further modulestore or
block transformer queries in order to discern the course structure when trying to
direct a learner to a section or unit.
Futhermore, we hypothesize that being able to build a Learning MFE courseware link
with just a unit ID or a section ID will be a nice simplifying quality for future
development or debugging.
## Decision
The courseware container will accept five total path forms:
1. `/course/:courseId`
2. `/course/:courseId/:sectionId`
3. `/course/:courseId/:sectionId/:unitId`
4. `/course/:courseId/:sequenceId`
5. `/course/:courseId/:unitId`
6. `/course/:courseId/:sequenceId/:unitId`
The redirection rules are as follows:
* Forms #1 redirects to Form #4 by selecting the first sequence in the course.
* Form #2 redirects to Form #4 by selecting to the first sequence in the section.
* Form #3 redirects to Form #5 by dropping the section ID.
* Form #4 redirects to Form #6 by choosing the active unit in the sequence
(or the first unit, if none are active).
* Form #5 redirects to Form #6 by filling in the ID of the sequence that the
specified unit belongs to (in the edge case where the unit belongs to multiple
sequences, the first sequence is selected).
As before, Form #5 is the canonocial courseware path, which is always redirected to
by any of the other courseware path forms.
## Consequences
The above decision is implemented.
## Further work
At some point, we may decide to further extend the URL scheme to be
more human-readable.
We can't make UsageKeys themselves more readable because they're tied to student state,
but we could introduce a new optional `slug` field on Sequences,
which would be captured and propagated to the learning_sequences API.
We could eventually do something similar to Units, since those slugs only have to be sequence-local.
So eventually, URLs could look less like:
```
https://learning.edx.org/course/course-v1:edX+DemoX.1+2T2019/block-v1:edX+DemoX.1+2T2019+type@sequential+block@e0a61b3d5a2046949e76d12cac5df493/block-v1:edX+DemoX.1+2T2019+type@vertical+block@52dbad5a28e140f291a476f92ce11996
```
And more like:
```
https://learning.edx.org/course/course-v1:edX+DemoX.1+2T2019/Being_Social/Teams
```

View File

@@ -0,0 +1,62 @@
# Direction of Courseware APIs
In order to allow for greater flexibility and separation of concerns, we're going to stop using the Course Blocks API for navigational data, and pull that data from the Learning Sequences Outlines API instead. The intention is to give us four distinct layers of courseware that can eventually be composed in different ways:
* Learning Context Metadata
* Learning Context Navigation
* Sequence Navigation
* Unit Rendering
Note that "Learning Context" is a generalization of "Course" that includes other things like Content Libraries, Learning Pathways, and potentially other logical groupings of content.
This is a refinement of [0002-courseware-page-decisions.md](0002-courseware-page-decisions.md). The fundamental layers remain the same, but this document tries to better clarify the boundaries and path forward for these layers. We're not making these layers completely swappable/pluggable now, but we should separate the data access in a way that allows for that in the future.
## Background
We currently make four primary requests to the LMS when rendering courseware instructional content:
1. Course Metadata: `/api/courseware/course/{courseId}` (REST API)
2. Course Blocks API: `/api/courses/v2/blocks/?course_id={courseId}` (REST API)
3. Sequence Metadata: `/api/courseware/sequence/{sequenceUsageKey}` (REST API)
4. Unit: `/xblock/{unitBlockUsageKey}` (rendered in an iframe)
There is a significant amount of overlap between the Course Blocks API and the others at the moment, since Course Blocks takes a static snapshot of the entire tree of course content at once. There are a few problems with the current arrangement:
* It's slow and complex. The Course Blocks API can be difficult to maintain and reason about, and trickier to optimize.
* Assuming that all course structures are the same makes it difficult to support other content types, like LabXchange Learning Pathways or adaptive content.
* The overlap between Course Blocks and the other APIs means that there can be conflicts about the state.
## Motivating Vision
We have seen a desire to extend or enhance the courseware experience in various ways:
Learning Context Navigation
* Allowing for shorter, human-readable URLs in courseware.
* Smaller courses that do not need the current navigational hierarchy.
* LabXchange pathways.
Sequence Navigation
* Adaptive content, where the full list of units is not known up front.
* More limited navigation, where content is pushed linearly, without the ability to jump ahead.
* Different layouts for content browsing.
Unit Rendering
* Use of QTI content (currently serviced by cc2olx conversion).
* Desire to experiment with a next-gen version of XBlock.
* Use of entirely LTI units.
The idea would be to insulate each layer from the layers above and below it. Sequence rendering shouldn't be affected by whether or not it's in a two level hierarchy (Course → Section → Sequence), or a flat one (Course → Sequence). Learning Context Navigation should be able to reference Sequences without caring if a Sequence is an adaptive one or not. Sequences should be able to have a common interface to call Unit iframes, whether those Units are rendering XBlocks or QTI content.
Note that supporting these types of course structures would require downstream changes in other systems as well (e.g. analytics).
## Next Step: Removing use of the Course Blocks API.
The next step in this process is to remove the call to the Course Blocks API, and split its responsibilities across just the existing Learning Sequences Outline and Sequence Metadata APIs. This will involve at least a couple of steps.
### Complete rollout of Learning Sequences Outline calls.
We're currently in a transitional state between these APIs where the Learning Sequences Outline calls are only rolled out on a small handful of courses.
### Shift Sequence and Unit metadata to only come from Sequence Metadata API.
We currently pull this information from both Course Blocks and the Sequence Metadata API. We can consolidate on just the Sequence Metadata API. There is also server side optimization that can be done with the Sequence Metadata API calls as part of this work.

View File

@@ -1,11 +1,12 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', {
setupFiles: [
setupFilesAfterEnv: [
'<rootDir>/src/setupTest.js',
],
coveragePathIgnorePatterns: [
'src/setupTest.js',
'src/i18n',
'src/.*\\.exp\\..*',
],
});

View File

@@ -3,3 +3,8 @@
oeps: {}
owner: edx/platform-core-tnl
openedx-release:
# The openedx-release key is described in OEP-10:
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
ref: master

61186
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,15 +15,12 @@
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"is-es5": "es-check es5 ./dist/*.js",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"prepare": "husky install",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests"
},
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-learning#readme",
@@ -34,34 +31,49 @@
"url": "https://github.com/edx/frontend-app-learning/issues"
},
"dependencies": {
"@edx/frontend-component-footer": "^10.0.6",
"@edx/frontend-component-header": "^2.0.3",
"@edx/frontend-platform": "^1.3.1",
"@edx/paragon": "^7.2.1",
"@fortawesome/fontawesome-svg-core": "^1.2.26",
"@fortawesome/free-brands-svg-icons": "^5.12.0",
"@fortawesome/free-regular-svg-icons": "^5.12.0",
"@fortawesome/free-solid-svg-icons": "^5.12.0",
"@fortawesome/react-fontawesome": "^0.1.8",
"@reduxjs/toolkit": "^1.2.3",
"classnames": "^2.2.6",
"core-js": "^3.6.2",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-redux": "^7.1.3",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"redux": "^4.0.5",
"regenerator-runtime": "^0.13.3"
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.2.2",
"@edx/frontend-component-header": "2.4.6",
"@edx/frontend-lib-special-exams": "1.16.0",
"@edx/frontend-platform": "1.15.6",
"@edx/paragon": "19.13.6",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.1.18",
"@popperjs/core": "2.11.5",
"@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.1",
"core-js": "3.21.1",
"js-cookie": "3.0.1",
"lodash.camelcase": "4.3.0",
"prop-types": "15.8.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-helmet": "6.1.0",
"react-redux": "7.2.8",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"react-share": "4.4.0",
"redux": "4.1.2",
"regenerator-runtime": "0.13.9",
"reselect": "4.1.5",
"truncate-html": "1.0.4",
"util": "0.12.4"
},
"devDependencies": {
"@edx/frontend-build": "^3.0.0",
"codecov": "^3.6.1",
"es-check": "^5.1.0",
"glob": "^7.1.6",
"husky": "^3.1.0",
"jest": "^24.9.0",
"reactifex": "^1.1.1"
"@edx/frontend-build": "9.1.4",
"@edx/reactifex": "1.1.0",
"@pact-foundation/pact": "9.17.3",
"@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.4",
"@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0",
"codecov": "3.8.3",
"es-check": "6.2.1",
"husky": "7.0.4",
"jest": "27.5.1",
"rosie": "2.1.0"
}
}

View File

@@ -1,10 +1,14 @@
<!doctype html>
<html lang="en-us">
<head>
<title>Course | edX</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<title>Course | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
<% if (htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID) { %>
<script src="https://www.edx.org/optimizelyjs/<%= htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID %>.js"></script>
<% } %>
</head>
<body>
<div id="root"></div>

View File

@@ -1,30 +0,0 @@
import React from 'react';
import { Switch, Route, useRouteMatch } from 'react-router';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import PageLoading from './PageLoading';
export default () => {
const { path } = useRouteMatch();
return (
<div className="flex-grow-1">
<PageLoading srMessage={(
<FormattedMessage
id="learn.redirect.interstitial.message"
description="The screen-reader message when a page is about to redirect"
defaultMessage="Redirecting..."
/>
)}
/>
<Switch>
<Route
path={`${path}/course-home/:courseId`}
render={({ match }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/course/`);
}}
/>
</Switch>
</div>
);
};

View File

@@ -0,0 +1,155 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import {
FormattedMessage, FormattedDate, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import messages from './messages';
import AccessExpirationAlertMMP2P from './AccessExpirationAlertMMP2P';
function AccessExpirationAlert({ intl, payload }) {
/** [MM-P2P] Experiment */
const [showMMP2P, setShowMMP2P] = useState(!!window.experiment__home_alert_bShowMMP2P);
if (window.experiment__home_alert_showMMP2P === undefined) {
window.experiment__home_alert_showMMP2P = (val) => {
window.experiment__home_alert_bShowMMP2P = !!val;
setShowMMP2P(!!val);
};
}
const {
accessExpiration,
courseId,
org,
userTimezone,
analyticsPageName,
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
if (!accessExpiration) {
return null;
}
const {
expirationDate,
upgradeDeadline,
upgradeUrl,
} = accessExpiration;
/** [MM-P2P] Experiment */
if (showMMP2P) {
return (
<AccessExpirationAlertMMP2P payload={payload} />
);
}
const logClick = () => {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
org_key: org,
courserun_key: courseId,
linkCategory: 'FBE_banner',
linkName: `${analyticsPageName}_audit_access_expires`,
linkType: 'link',
pageName: analyticsPageName,
});
};
let deadlineMessage = null;
if (upgradeDeadline && upgradeUrl) {
deadlineMessage = (
<>
<br />
<FormattedMessage
id="learning.accessExpiration.deadline"
defaultMessage="Upgrade by {date} to get unlimited access to the course as long as it exists on the site."
description="Warning shown to learner to upgrade while they are enrolled on the audit version and it's possible to upgrade"
values={{
date: (
<FormattedDate
key="accessExpirationUpgradeDeadline"
day="numeric"
month="short"
year="numeric"
value={upgradeDeadline}
{...timezoneFormatArgs}
/>
),
}}
/>
&nbsp;
<Hyperlink
className="font-weight-bold"
style={{ textDecoration: 'underline' }}
destination={upgradeUrl}
onClick={logClick}
>
{intl.formatMessage(messages.upgradeNow)}
</Hyperlink>
</>
);
}
return (
<Alert variant="info" icon={Info}>
<span className="font-weight-bold">
<FormattedMessage
id="learning.accessExpiration.header"
defaultMessage="Audit Access Expires {date}"
description="Headline for auditing deadline"
values={{
date: (
<FormattedDate
key="accessExpirationHeaderDate"
day="numeric"
month="short"
year="numeric"
value={expirationDate}
{...timezoneFormatArgs}
/>
),
}}
/>
</span>
<br />
<FormattedMessage
id="learning.accessExpiration.body"
defaultMessage="You lose all access to this course, including your progress, on {date}."
description="Message body to tell learner the consequences of course expiration."
values={{
date: (
<FormattedDate
key="accessExpirationBodyDate"
day="numeric"
month="short"
year="numeric"
value={expirationDate}
{...timezoneFormatArgs}
/>
),
}}
/>
{deadlineMessage}
</Alert>
);
}
AccessExpirationAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
accessExpiration: PropTypes.shape({
expirationDate: PropTypes.string.isRequired,
masqueradingExpiredCourse: PropTypes.bool.isRequired,
upgradeDeadline: PropTypes.string,
upgradeUrl: PropTypes.string,
}).isRequired,
courseId: PropTypes.string.isRequired,
org: PropTypes.string.isRequired,
userTimezone: PropTypes.string.isRequired,
analyticsPageName: PropTypes.string.isRequired,
}).isRequired,
};
export default injectIntl(AccessExpirationAlert);

View File

@@ -0,0 +1,80 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedDate, injectIntl } from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import messages from './messages';
function AccessExpirationAlertMMP2P({ payload }) {
const {
accessExpiration,
userTimezone,
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
if (!accessExpiration) {
return null;
}
const {
expirationDate,
upgradeDeadline,
upgradeUrl,
} = accessExpiration;
let deadlineMessage = null;
const formatDate = (val, key) => (
<FormattedDate
key={`accessExpiration.${key}`}
day="numeric"
month="short"
year="numeric"
value={val}
{...timezoneFormatArgs}
/>
);
if (upgradeDeadline && upgradeUrl) {
deadlineMessage = (
<>
Upgrade by {formatDate(upgradeDeadline, 'upgradeDesc')} to unlock unlimited access to all course activities, including graded assignments.
&nbsp;
<Hyperlink
className="font-weight-bold"
style={{ textDecoration: 'underline' }}
destination={upgradeUrl}
>
{messages.upgradeNow.defaultMessage}
</Hyperlink>
</>
);
}
return (
<Alert variant="info" icon={Info}>
<span className="font-weight-bold">
Unlock full course content by {formatDate(upgradeDeadline, 'upgradeTitle')}
</span>
<br />
{deadlineMessage}
<br />
You lose all access to the first two weeks of scheduled content
on {formatDate(expirationDate, 'expirationBody')}.
</Alert>
);
}
AccessExpirationAlertMMP2P.propTypes = {
payload: PropTypes.shape({
accessExpiration: PropTypes.shape({
expirationDate: PropTypes.string.isRequired,
masqueradingExpiredCourse: PropTypes.bool.isRequired,
upgradeDeadline: PropTypes.string,
upgradeUrl: PropTypes.string,
}).isRequired,
userTimezone: PropTypes.string.isRequired,
}).isRequired,
};
export default injectIntl(AccessExpirationAlertMMP2P);

View File

@@ -0,0 +1,39 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@edx/paragon';
function AccessExpirationMasqueradeBanner({ payload }) {
const {
expirationDate,
userTimezone,
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<PageBanner variant="warning">
<FormattedMessage
id="instructorToolbar.pageBanner.courseHasExpired"
defaultMessage="This learner no longer has access to this course. Their access expired on {date}."
description="It's a warning that is shown to course author when being masqueraded as learner, while the course has expired for the real learner."
values={{
date: <FormattedDate
key="instructorToolbar.pageBanner.accessExpirationDate"
value={expirationDate}
{...timezoneFormatArgs}
/>,
}}
/>
</PageBanner>
);
}
AccessExpirationMasqueradeBanner.propTypes = {
payload: PropTypes.shape({
expirationDate: PropTypes.string.isRequired,
userTimezone: PropTypes.string.isRequired,
}).isRequired,
};
export default AccessExpirationMasqueradeBanner;

View File

@@ -0,0 +1,51 @@
import React, { useMemo } from 'react';
import { useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
const AccessExpirationMasqueradeBanner = React.lazy(() => import('./AccessExpirationMasqueradeBanner'));
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) {
const isVisible = accessExpiration && !accessExpiration.masqueradingExpiredCourse; // If it exists, show it.
const payload = {
accessExpiration,
courseId,
org,
userTimezone,
analyticsPageName,
};
useAlert(isVisible, {
code: 'clientAccessExpirationAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic,
});
return { clientAccessExpirationAlert: AccessExpirationAlert };
}
export function useAccessExpirationMasqueradeBanner(courseId, tab) {
const {
userTimezone,
} = useModel('courseHomeMeta', courseId);
const {
accessExpiration,
} = useModel(tab, courseId);
const isVisible = accessExpiration && accessExpiration.masqueradingExpiredCourse;
const expirationDate = accessExpiration && accessExpiration.expirationDate;
const payload = {
expirationDate,
userTimezone,
};
useAlert(isVisible, {
code: 'clientAccessExpirationMasqueradeBanner',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'instructor-toolbar-alerts',
});
return { clientAccessExpirationMasqueradeBanner: AccessExpirationMasqueradeBanner };
}
export default useAccessExpirationAlert;

View File

@@ -0,0 +1 @@
export { default, useAccessExpirationMasqueradeBanner } from './hooks';

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
upgradeNow: {
id: 'learning.accessExpiration.upgradeNow',
defaultMessage: 'Upgrade now',
description: 'The anchor text for the upgrading link',
},
});
export default messages;

View File

@@ -0,0 +1,101 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedDate,
FormattedMessage,
FormattedRelative,
FormattedTime,
} from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { useModel } from '../../generic/model-store';
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
function CourseStartAlert({ payload }) {
const {
courseId,
} = payload;
const {
start: startDate,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const timeRemaining = (
<FormattedRelative
key="timeRemaining"
value={startDate}
{...timezoneFormatArgs}
/>
);
const delta = new Date(startDate) - new Date();
if (delta < DAY_MS) {
return (
<Alert variant="info" icon={Info}>
<FormattedMessage
id="learning.outline.alert.start.short"
defaultMessage="Course starts {timeRemaining} at {courseStartTime}."
description="Used when the time remaining is less than a day away."
values={{
courseStartTime: (
<FormattedTime
key="courseStartTime"
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={startDate}
{...timezoneFormatArgs}
/>
),
timeRemaining,
}}
/>
</Alert>
);
}
return (
<Alert variant="info" icon={Info}>
<strong>
<FormattedMessage
id="learning.outline.alert.end.long"
defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
description="Used when the time remaining is more than a day away."
values={{
courseStartDate: (
<FormattedDate
key="courseStartDate"
day="numeric"
month="short"
year="numeric"
value={startDate}
{...timezoneFormatArgs}
/>
),
timeRemaining,
}}
/>
</strong>
<br />
<FormattedMessage
id="learning.outline.alert.end.calendar"
defaultMessage="Dont forget to add a calendar reminder!"
description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. "
/>
</Alert>
);
}
CourseStartAlert.propTypes = {
payload: PropTypes.shape({
courseId: PropTypes.string,
}).isRequired,
};
export default CourseStartAlert;

View File

@@ -0,0 +1,44 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@edx/paragon';
import { useModel } from '../../generic/model-store';
function CourseStartMasqueradeBanner({ payload }) {
const {
courseId,
} = payload;
const {
start,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<PageBanner variant="warning">
<FormattedMessage
id="instructorToolbar.pageBanner.courseHasNotStarted"
defaultMessage="This learner does not yet have access to this course. The course starts on {date}."
description="It's a warning that is shown to course author when being masqueraded as learner, while the course hasn't started for the real learner yet."
values={{
date: <FormattedDate
key="instructorToolbar.pageBanner.courseStartDate"
value={start}
{...timezoneFormatArgs}
/>,
}}
/>
</PageBanner>
);
}
CourseStartMasqueradeBanner.propTypes = {
payload: PropTypes.shape({
courseId: PropTypes.string.isRequired,
}).isRequired,
};
export default CourseStartMasqueradeBanner;

View File

@@ -0,0 +1,62 @@
import React, { useMemo } from 'react';
import { useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
const CourseStartMasqueradeBanner = React.lazy(() => import('./CourseStartMasqueradeBanner'));
function isStartDateInFuture(courseId) {
const {
start,
} = useModel('courseHomeMeta', courseId);
const today = new Date();
const startDate = new Date(start);
return startDate > today;
}
function useCourseStartAlert(courseId) {
const {
isEnrolled,
} = useModel('courseHomeMeta', courseId);
const isVisible = isEnrolled && isStartDateInFuture(courseId);
const payload = {
courseId,
};
useAlert(isVisible, {
code: 'clientCourseStartAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',
});
return {
clientCourseStartAlert: CourseStartAlert,
};
}
export function useCourseStartMasqueradeBanner(courseId, tab) {
const {
isMasquerading,
} = useModel('courseHomeMeta', courseId);
const isVisible = isMasquerading && tab === 'progress' && isStartDateInFuture(courseId);
const payload = {
courseId,
};
useAlert(isVisible, {
code: 'clientCourseStartMasqueradeBanner',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'instructor-toolbar-alerts',
});
return {
clientCourseStartMasqueradeBanner: CourseStartMasqueradeBanner,
};
}
export default useCourseStartAlert;

View File

@@ -0,0 +1 @@
export { default, useCourseStartMasqueradeBanner } from './hooks';

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Alert, Button } from '@edx/paragon';
import { Info, WarningFilled } from '@edx/paragon/icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { useModel } from '../../generic/model-store';
import messages from './messages';
import useEnrollClickHandler from './clickHook';
function EnrollmentAlert({ intl, payload }) {
const {
canEnroll,
courseId,
extraText,
isStaff,
} = payload;
const {
org,
} = useModel('courseHomeMeta', courseId);
const { enrollClickHandler, loading } = useEnrollClickHandler(
courseId,
org,
intl.formatMessage(messages.success),
);
let text = intl.formatMessage(messages.alert);
let type = 'warning';
let icon = WarningFilled;
if (isStaff) {
text = intl.formatMessage(messages.staffAlert);
type = 'info';
icon = Info;
} else if (extraText) {
text = `${text} ${extraText}`;
}
const button = canEnroll && (
<Button disabled={loading} variant="link" className="p-0 border-0 align-top mx-1" size="sm" style={{ textDecoration: 'underline' }} onClick={enrollClickHandler}>
{intl.formatMessage(messages.enrollNowSentence)}
</Button>
);
return (
<Alert variant={type} icon={icon}>
<div className="d-flex">
{text}
{button}
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
</div>
</Alert>
);
}
EnrollmentAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
canEnroll: PropTypes.bool,
courseId: PropTypes.string,
extraText: PropTypes.string,
isStaff: PropTypes.bool,
}).isRequired,
};
export default injectIntl(EnrollmentAlert);

View File

@@ -0,0 +1,35 @@
import { useContext, useState, useCallback } from 'react';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { UserMessagesContext, ALERT_TYPES } from '../../generic/user-messages';
import { postCourseEnrollment } from './data/api';
// Separated into its own file to avoid a circular dependency inside this directory
function useEnrollClickHandler(courseId, orgId, successText) {
const [loading, setLoading] = useState(false);
const { addFlash } = useContext(UserMessagesContext);
const enrollClickHandler = useCallback(() => {
setLoading(true);
postCourseEnrollment(courseId).then(() => {
addFlash({
dismissible: true,
flash: true,
text: successText,
type: ALERT_TYPES.SUCCESS,
topic: 'course',
});
setLoading(false);
sendTrackEvent('edx.bi.user.course-home.enrollment', {
org_key: orgId,
courserun_key: courseId,
});
global.location.reload();
});
}, [courseId]);
return { enrollClickHandler, loading };
}
export default useEnrollClickHandler;

View File

@@ -0,0 +1,9 @@
/* eslint-disable import/prefer-default-export */
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
export async function postCourseEnrollment(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
const { data } = await getAuthenticatedHttpClient().post(url, { course_details: { course_id: courseId } });
return data;
}

View File

@@ -0,0 +1,39 @@
/* eslint-disable import/prefer-default-export */
import React, {
useContext, useMemo,
} from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert'));
export function useEnrollmentAlert(courseId) {
const { authenticatedUser } = useContext(AppContext);
const course = useModel('courseHomeMeta', courseId);
const outline = useModel('outline', courseId);
const enrolledUser = course && course.isEnrolled !== undefined && course.isEnrolled;
const privateOutline = outline && outline.courseBlocks && !outline.courseBlocks.courses;
/**
* This alert should render if
* 1. the user is not enrolled,
* 2. the user is authenticated, AND
* 3. the course is private.
*/
const isVisible = !enrolledUser && authenticatedUser !== null && privateOutline;
const payload = {
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
courseId,
extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '',
isStaff: course && course.isStaff,
};
useAlert(isVisible, {
code: 'clientEnrollmentAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline',
});
return { clientEnrollmentAlert: EnrollmentAlert };
}

View File

@@ -0,0 +1 @@
export { useEnrollmentAlert as default } from './hooks';

View File

@@ -1,21 +1,32 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'learning.enrollment.alert': {
alert: {
id: 'learning.enrollment.alert',
defaultMessage: 'You must be enrolled in the course to see course content.',
description: 'Message shown to indicate that a user needs to enroll in a course prior to viewing the course content. Shown as part of an alert, along with a link to enroll.',
},
'learning.staff.enrollment.alert': {
staffAlert: {
id: 'learning.staff.enrollment.alert',
defaultMessage: 'You are viewing this course as staff, and are not enrolled.',
description: 'Message shown to indicate that a user is not enrolled, but is able to view a course anyway because they are staff. Shown as part of an alert, along with a link to enroll.',
},
'learning.enrollment.enroll.now': {
id: 'learning.enrollment.enroll.now',
defaultMessage: 'Enroll Now',
enrollNowInline: {
id: 'learning.enrollment.enrollNow.Inline',
defaultMessage: 'Enroll now',
description: 'A link prompting the user to click on it to enroll in the currently viewed course.'
+ 'This text is meant to be used at the beginning of a sentence (example: Enroll now to view course content.)',
},
enrollNowSentence: {
id: 'learning.enrollment.enrollNow.Sentence',
defaultMessage: 'Enroll now.',
description: 'A link prompting the user to click on it to enroll in the currently viewed course.',
},
success: {
id: 'learning.enrollment.success',
defaultMessage: "You've successfully enrolled in this course!",
description: 'A message telling the user that their course enrollment was successful.',
},
});
export default messages;

View File

@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import Cookies from 'js-cookie';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import {
AlertModal,
Button,
Spinner,
Icon,
} from '@edx/paragon';
import { Check, ArrowForward } from '@edx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { sendActivationEmail } from '../../courseware/data';
import messages from './messages';
function AccountActivationAlert({
intl,
}) {
const [showModal, setShowModal] = useState(false);
const [showSpinner, setShowSpinner] = useState(false);
const [showCheck, setShowCheck] = useState(false);
const handleOnClick = () => {
setShowSpinner(true);
setShowCheck(false);
sendActivationEmail().then(() => {
setShowSpinner(false);
setShowCheck(true);
});
};
const showAccountActivationAlert = Cookies.get('show-account-activation-popup');
if (showAccountActivationAlert !== undefined) {
Cookies.remove('show-account-activation-popup', { path: '/', domain: process.env.SESSION_COOKIE_DOMAIN });
// extra check to make sure cookie was removed before updating the state. Updating the state without removal
// of cookie would make it infinite rendering
if (Cookies.get('show-account-activation-popup') === undefined) {
setShowModal(true);
}
}
const button = (
<Button
variant="primary"
className=""
onClick={() => setShowModal(false)}
>
<FormattedMessage
id="account-activation.alert.button"
defaultMessage="Continue to {siteName}"
description="account activation alert continue button"
values={{
siteName: getConfig().SITE_NAME,
}}
/>
<Icon src={ArrowForward} className="ml-1 d-inline-block align-bottom" />
</Button>
);
const children = () => {
let bodyContent;
const message = (
<FormattedMessage
id="account-activation.alert.message"
defaultMessage="We sent an email to {boldEmail} with a link to activate your account. Cant find it? Check your spam folder or
{sendEmailTag}."
description="Message for account activation alert which is shown after the registration"
values={{
boldEmail: <b>{getAuthenticatedUser() && getAuthenticatedUser().email}</b>,
sendEmailTag: (
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a href="#" role="button" onClick={handleOnClick}>
<FormattedMessage
id="account-activation.resend.link"
defaultMessage="resend the email"
description="Message for resend link in account activation alert which is shown after the registration"
/>
</a>
),
}}
/>
);
bodyContent = (
<div>
{message}
</div>
);
if (!showCheck && showSpinner) {
bodyContent = (
<div>
{message}
<Spinner
animation="border"
variant="secondary"
style={{ height: '1.5rem', width: '1.5rem' }}
/>
</div>
);
}
if (showCheck && !showSpinner) {
bodyContent = (
<div>
{message}
<Icon
src={Check}
style={{ height: '1.7rem', width: '1.25rem' }}
className="text-success-500 d-inline-block position-fixed"
/>
</div>
);
}
return bodyContent;
};
return (
<AlertModal
isOpen={showModal}
title={intl.formatMessage(messages.accountActivationAlertTitle)}
footerNode={button}
onClose={() => ({})}
>
{children()}
</AlertModal>
);
}
AccountActivationAlert.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(AccountActivationAlert);

View File

@@ -2,31 +2,38 @@ import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { Alert, Hyperlink } from '@edx/paragon';
import { WarningFilled } from '@edx/paragon/icons';
import Alert from '../user-messages/Alert';
import messages from './messages';
import genericMessages from '../../generic/messages';
function LogistrationAlert({ intl }) {
const signIn = (
<a href={`${getLoginRedirectUrl(global.location.href)}`}>
{intl.formatMessage(messages['learning.logistration.login'])}
</a>
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={`${getLoginRedirectUrl(global.location.href)}`}
>
{intl.formatMessage(genericMessages.signInLowercase)}
</Hyperlink>
);
// TODO: Pull this registration URL building out into a function, like the login one above.
// This is complicated by the fact that we don't have a REGISTER_URL env variable available.
const register = (
<a href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}>
{intl.formatMessage(messages['learning.logistration.register'])}
</a>
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
>
{intl.formatMessage(genericMessages.registerLowercase)}
</Hyperlink>
);
return (
<Alert type="error">
<Alert variant="warning" icon={WarningFilled}>
<FormattedMessage
id="learning.logistration.alert"
description="Prompts the user to sign in or register to see course content."
defaultMessage="Please {signIn} or {register} to see course content."
defaultMessage="To see course content, {signIn} or {register}."
values={{
signIn,
register,

View File

@@ -0,0 +1,28 @@
/* eslint-disable import/prefer-default-export */
import React, { useContext } from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
const LogistrationAlert = React.lazy(() => import('./LogistrationAlert'));
export function useLogistrationAlert(courseId) {
const { authenticatedUser } = useContext(AppContext);
const outline = useModel('outline', courseId);
const privateOutline = outline && outline.courseBlocks && !outline.courseBlocks.courses;
/**
* This alert should render if
* 1. the user is not authenticated, AND
* 2. the course is private.
*/
const isVisible = authenticatedUser === null && privateOutline;
useAlert(isVisible, {
code: 'clientLogistrationAlert',
topic: 'outline',
dismissible: false,
type: ALERT_TYPES.ERROR,
});
return { clientLogistrationAlert: LogistrationAlert };
}

View File

@@ -0,0 +1 @@
export { useLogistrationAlert as default } from './hooks';

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
accountActivationAlertTitle: {
id: 'account-activation.alert.title',
defaultMessage: 'Activate your account so you can log back in',
description: 'Title for account activation alert which is shown after the registration',
},
});
export default messages;

View File

@@ -0,0 +1,56 @@
import { useSelector } from 'react-redux';
import { useModel } from '../../generic/model-store';
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
import messages from './messages';
function useSequenceBannerTextAlert(sequenceId) {
const sequence = useModel('sequences', sequenceId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
// Show Alert that comes along with the sequence
useAlert(sequenceStatus === 'loaded' && sequence.bannerText, {
code: null,
dismissible: false,
text: sequence.bannerText,
type: ALERT_TYPES.INFO,
topic: 'sequence',
});
}
function useSequenceEntranceExamAlert(courseId, sequenceId, intl) {
const course = useModel('coursewareMeta', courseId);
const sequence = useModel('sequences', sequenceId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const {
entranceExamCurrentScore,
entranceExamEnabled,
entranceExamId,
entranceExamMinimumScorePct,
entranceExamPassed,
} = course.entranceExamData || {};
const entranceExamAlertVisible = sequenceStatus === 'loaded' && entranceExamEnabled && entranceExamId === sequence.sectionId;
let entranceExamText;
if (entranceExamPassed) {
entranceExamText = intl.formatMessage(
messages.entranceExamTextPassed, { entranceExamCurrentScore: entranceExamCurrentScore * 100 },
);
} else {
entranceExamText = intl.formatMessage(messages.entranceExamTextNotPassing, {
entranceExamCurrentScore: entranceExamCurrentScore * 100,
entranceExamMinimumScorePct: entranceExamMinimumScorePct * 100,
});
}
useAlert(entranceExamAlertVisible, {
code: null,
dismissible: false,
text: entranceExamText,
type: ALERT_TYPES.INFO,
topic: 'sequence',
});
}
export { useSequenceBannerTextAlert, useSequenceEntranceExamAlert };

View File

@@ -0,0 +1,14 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
entranceExamTextNotPassing: {
id: 'learn.sequence.entranceExamTextNotPassing',
defaultMessage: 'To access course materials, you must score {entranceExamMinimumScorePct}% or higher on this exam. Your current score is {entranceExamCurrentScore}%.',
},
entranceExamTextPassed: {
id: 'learn.sequence.entranceExamTextPassed',
defaultMessage: 'Your score is {entranceExamCurrentScore}%. You have passed the entrance exam.',
},
});
export default messages;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,74 +0,0 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { Dropdown } from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { AppContext } from '@edx/frontend-platform/react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
import logo from './assets/logo.svg';
function LinkedLogo({
href,
src,
alt,
...attributes
}) {
return (
<a href={href} {...attributes}>
<img className="d-block" src={src} alt={alt} />
</a>
);
}
LinkedLogo.propTypes = {
href: PropTypes.string.isRequired,
src: PropTypes.string.isRequired,
alt: PropTypes.string.isRequired,
};
export default function Header({
courseOrg, courseNumber, courseTitle,
}) {
const { authenticatedUser } = useContext(AppContext);
return (
<header className="course-header">
<div className="container-fluid py-2 d-flex align-items-center ">
<LinkedLogo
className="logo"
href={`${getConfig().LMS_BASE_URL}/dashboard`}
src={logo}
alt={getConfig().SITE_NAME}
/>
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
</div>
<Dropdown className="user-dropdown">
<Dropdown.Button>
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
<span className="d-none d-md-inline">
{authenticatedUser.username}
</span>
</Dropdown.Button>
<Dropdown.Menu className="dropdown-menu-right">
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>Dashboard</Dropdown.Item>
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${authenticatedUser.username}`}>Profile</Dropdown.Item>
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>Account</Dropdown.Item>
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>Order History</Dropdown.Item>
<Dropdown.Item href={getConfig().LOGOUT_URL}>Sign Out</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
</header>
);
}
Header.propTypes = {
courseOrg: PropTypes.string.isRequired,
courseNumber: PropTypes.string.isRequired,
courseTitle: PropTypes.string.isRequired,
};

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1168px" height="540px" viewBox="0 0 1168 540" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 53.2 (72643) - https://sketchapp.com -->
<title>logo</title>
<desc>Created with Sketch.</desc>
<g id="logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<polygon id="Path" fill="#209FDA" fill-rule="nonzero" points="1166.81993 85.5 1166.81993 2.84217094e-14 953.759925 2.84217094e-14 953.759925 85.5 1002.17993 85.5 915.859925 191.98 829.459925 85.5 878.099925 85.5 878.099925 2.84217094e-14 718.919925 2.84217094e-14 718.919925 95.72 856.479925 265.26 718.919925 434.96 718.919925 452.02 784.499925 452.02 784.499925 539.64 878.099925 539.64 878.099925 452.02 823.919925 452.02 915.919925 338.52 915.939925 338.52 1008.03993 452.02 953.759925 452.02 953.759925 539.64 1166.81993 539.64 1166.81993 452.02 1126.85993 452.02 975.319925 265.26 1121.01993 85.5"></polygon>
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="664.019925 7.10542736e-15 664.019925 85.5 710.619925 85.5 718.919925 95.72 718.919925 7.10542736e-15"></polygon>
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="718.919925 452.02 718.919925 434.96 705.079925 452.02 664.019925 452.02 664.019925 539.64 784.499925 539.64 784.499925 452.02"></polygon>
<path d="M321.999925,411.86 L397.659925,411.86 C388.805702,433.829527 376.258024,454.122269 360.559925,471.86 C344.364089,454.216816 331.320914,433.921419 321.999925,411.86" id="Path" fill="#78212E" fill-rule="nonzero"></path>
<path d="M360.559925,189.28 C338.58337,213.190393 322.501981,241.908137 313.599925,273.14 C317.134915,280.039338 320.007771,287.25831 322.179925,294.7 L397.059925,294.7 C399.306706,287.354671 402.25356,280.242036 405.859925,273.46 C397.464721,242.277678 381.959326,213.464341 360.559925,189.28 Z M322.179925,294.7 C328.784599,317.438017 328.978396,341.558795 322.739925,364.4 L396.399925,364.4 C389.855554,341.597488 390.06397,317.386469 396.999925,294.7 L322.179925,294.7 Z M322.179925,294.7 L308.679925,294.7 C304.690779,317.752715 304.575868,341.309464 308.339925,364.4 L322.739925,364.4 C328.978396,341.558795 328.784599,317.438017 322.179925,294.7 L322.179925,294.7 Z" id="Shape" fill="#78212E" fill-rule="nonzero"></path>
<path d="M710.619925,85.5 L664.019925,85.5 L664.019925,0.02 L576.019925,0.02 L576.019925,85.5 L632.859925,85.5 L632.859925,159.2 C598.417874,134.487772 557.04992,121.286425 514.659925,121.48 C456.044663,121.405246 400.107354,146.01621 360.559925,189.28 C381.937732,213.470272 397.422343,242.283149 405.799925,273.46 C426.944121,233.500977 468.451514,208.51034 513.659925,208.52 C581.059925,208.52 632.879925,263.16 632.879925,330.52 L632.879925,331.2 C632.539925,398.28 580.879925,452.56 513.659925,452.56 C468.477451,452.593197 426.976426,427.652566 405.799925,387.74 L405.799925,387.74 C401.869213,380.340239 398.718926,372.551658 396.399925,364.5 L308.399925,364.5 C309.686934,372.450225 311.443338,380.317312 313.659925,388.06 C315.970162,396.190434 318.775397,404.171995 322.059925,411.96 L397.659925,411.96 C388.805702,433.929527 376.258024,454.222269 360.559925,471.96 C400.107354,515.22379 456.044663,539.834754 514.659925,539.76 C571.465111,540.091874 625.745998,516.316729 664.019925,474.34 L664.019925,452.04 L705.059925,452.04 L718.899925,434.96 L718.899925,95.74 L710.619925,85.5 Z M632.879925,501.9 L632.879925,539.74 L664.019925,539.74 L664.019925,474.18 C654.623775,484.469293 644.18821,493.758755 632.879925,501.9 L632.879925,501.9 Z M313.599925,273.14 C311.569597,280.231983 309.927163,287.429316 308.679925,294.7 L322.179925,294.7 C320.007771,287.25831 317.134915,280.039338 313.599925,273.14 L313.599925,273.14 Z" id="Shape" fill="#8A8C8F" fill-rule="nonzero"></path>
<path d="M410.399925,294.7 C409.199925,287.5 407.659925,280.4 405.799925,273.46 C402.19356,280.242036 399.246706,287.354671 396.999925,294.7 C390.06397,317.386469 389.855554,341.597488 396.399925,364.4 L410.719925,364.4 C414.264276,341.293291 414.156293,317.77319 410.399925,294.7 L410.399925,294.7 Z M209.059925,121.48 C107.422724,121.487508 20.5081632,194.571683 3.05992537,294.7 L91.3999254,294.7 C107.135726,243.467257 154.465065,208.503753 208.059925,208.52 C252.638644,208.335148 293.496156,233.351373 313.599925,273.14 C322.501981,241.908137 338.58337,213.190393 360.559925,189.28 C322.206855,145.880863 266.976617,121.163964 209.059925,121.48 L209.059925,121.48 Z M297.479925,411.86 C275.077969,437.877726 242.392659,452.761934 208.059925,452.58 C153.691226,452.598435 105.87164,416.63791 90.7999254,364.4 L308.339925,364.4 C304.575868,341.309464 304.690779,317.752715 308.679925,294.7 L3.05992537,294.7 C-0.902504563,317.755068 -1.01739385,341.307372 2.71992537,364.4 L2.71992537,364.4 C19.3292424,465.441984 106.661918,539.594765 209.059925,539.6 C266.986094,539.900862 322.217868,515.161403 360.559925,471.74 C344.364089,454.096816 331.320914,433.801419 321.999925,411.74 L297.479925,411.86 Z" id="Shape" fill="#B72768" fill-rule="nonzero"></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -1,41 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
export default function CourseDates({
start,
end,
enrollmentStart,
enrollmentEnd,
enrollmentMode,
isEnrolled,
}) {
return (
<section>
<h4>Upcoming Dates</h4>
<div><strong>Course Start:</strong><br />{start}</div>
<div><strong>Course End:</strong><br />{end}</div>
<div><strong>Enrollment Start:</strong><br />{enrollmentStart}</div>
<div><strong>Enrollment End:</strong><br />{enrollmentEnd}</div>
<div><strong>Mode:</strong><br />{enrollmentMode}</div>
<div>{isEnrolled ? 'Active Enrollment' : 'Inactive Enrollment'}</div>
</section>
);
}
CourseDates.propTypes = {
start: PropTypes.string,
end: PropTypes.string,
enrollmentStart: PropTypes.string,
enrollmentEnd: PropTypes.string,
enrollmentMode: PropTypes.string,
isEnrolled: PropTypes.bool,
};
CourseDates.defaultProps = {
start: null,
end: null,
enrollmentStart: null,
enrollmentEnd: null,
enrollmentMode: null,
isEnrolled: false,
};

View File

@@ -1,97 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import AlertList from '../user-messages/AlertList';
import { Header, CourseTabsNavigation } from '../course-header';
import { useLogistrationAlert } from '../logistration-alert';
import { useEnrollmentAlert } from '../enrollment-alert';
import CourseDates from './CourseDates';
import Section from './Section';
import { useModel } from '../model-store';
// Note that we import from the component files themselves in the enrollment-alert package.
// This is because Reacy.lazy() requires that we import() from a file with a Component as it's
// default export.
// See React.lazy docs here: https://reactjs.org/docs/code-splitting.html#reactlazy
const { EnrollmentAlert, StaffEnrollmentAlert } = React.lazy(() => import('../enrollment-alert'));
const LogistrationAlert = React.lazy(() => import('../logistration-alert'));
export default function CourseHome({
courseId,
}) {
useLogistrationAlert();
useEnrollmentAlert(courseId);
const {
org,
number,
title,
start,
end,
enrollmentStart,
enrollmentEnd,
enrollmentMode,
isEnrolled,
tabs,
sectionIds,
} = useModel('courses', courseId);
return (
<>
<Header
courseOrg={org}
courseNumber={number}
courseTitle={title}
/>
<main className="d-flex flex-column flex-grow-1">
<div className="container-fluid">
<CourseTabsNavigation tabs={tabs} className="mb-3" activeTabSlug="courseware" />
<AlertList
topic="outline"
className="mb-3"
customAlerts={{
clientEnrollmentAlert: EnrollmentAlert,
clientStaffEnrollmentAlert: StaffEnrollmentAlert,
clientLogistrationAlert: LogistrationAlert,
}}
/>
</div>
<div className="flex-grow-1">
<div className="container-fluid">
<div className="d-flex justify-content-between mb-3">
<h2>{title}</h2>
<Button className="btn-primary" type="button">Resume Course</Button>
</div>
<div className="row">
<div className="col col-8">
{sectionIds.map((sectionId) => (
<Section
key={sectionId}
id={sectionId}
courseId={courseId}
/>
))}
</div>
<div className="col col-4">
<CourseDates
start={start}
end={end}
enrollmentStart={enrollmentStart}
enrollmentEnd={enrollmentEnd}
enrollmentMode={enrollmentMode}
isEnrolled={isEnrolled}
/>
</div>
</div>
</div>
</div>
</main>
</>
);
}
CourseHome.propTypes = {
courseId: PropTypes.string.isRequired,
};

View File

@@ -1,54 +0,0 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import PageLoading from '../PageLoading';
import CourseHome from './CourseHome';
import { fetchCourse } from '../data';
function CourseHomeContainer(props) {
const {
intl,
match,
} = props;
const dispatch = useDispatch();
useEffect(() => {
// The courseId from the URL is the course we WANT to load.
dispatch(fetchCourse(match.params.courseId));
}, [match.params.courseId]);
// The courseId from the store is the course we HAVE loaded. If the URL changes,
// we don't want the application to adjust to it until it has actually loaded the new data.
const {
courseId,
courseStatus,
} = useSelector(state => state.courseware);
return (
<>
{courseStatus === 'loaded' ? (
<CourseHome
courseId={courseId}
/>
) : (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.outline'])}
/>
)}
</>
);
}
CourseHomeContainer.propTypes = {
intl: intlShape.isRequired,
match: PropTypes.shape({
params: PropTypes.shape({
courseId: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
};
export default injectIntl(CourseHomeContainer);

View File

@@ -1,44 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Collapsible } from '@edx/paragon';
import { faChevronRight, faChevronDown } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import SequenceLink from './SequenceLink';
import { useModel } from '../model-store';
export default function Section({ id, courseId }) {
const section = useModel('sections', id);
const { title, sequenceIds } = section;
return (
<Collapsible.Advanced className="collapsible-card mb-2">
<Collapsible.Trigger className="collapsible-trigger d-flex align-items-start">
<Collapsible.Visible whenClosed>
<div style={{ minWidth: '1rem' }}>
<FontAwesomeIcon icon={faChevronRight} />
</div>
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<div style={{ minWidth: '1rem' }}>
<FontAwesomeIcon icon={faChevronDown} />
</div>
</Collapsible.Visible>
<div className="ml-2 flex-grow-1">{title}</div>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body">
{sequenceIds.map((sequenceId) => (
<SequenceLink
key={sequenceId}
id={sequenceId}
courseId={courseId}
/>
))}
</Collapsible.Body>
</Collapsible.Advanced>
);
}
Section.propTypes = {
id: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
};

View File

@@ -1,18 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { useModel } from '../model-store';
export default function SequenceLink({ id, courseId }) {
const sequence = useModel('sequences', id);
return (
<div className="ml-4">
<Link to={`/course/${courseId}/${id}`}>{sequence.title}</Link>
</div>
);
}
SequenceLink.propTypes = {
id: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,125 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import courseMetadataBase from '../../../shared/data/__factories__/courseMetadataBase.factory';
Factory.define('courseHomeMetadata')
.extend(courseMetadataBase)
.option('host', 'http://localhost:18000')
.attrs({
title: 'Demonstration Course',
is_self_paced: false,
is_enrolled: false,
is_staff: false,
can_load_courseware: true,
can_view_certificate: true,
celebrations: null,
course_access: {
additional_context_user_message: null,
developer_message: null,
error_code: null,
has_access: true,
user_fragment: null,
user_message: null,
},
number: 'DemoX',
original_user_is_staff: false,
org: 'edX',
start: '2013-02-05T05:00:00Z',
user_timezone: 'UTC',
username: 'MockUser',
verified_mode: {
access_expiration_date: null,
currency: 'USD',
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
sku: '8CF08E5',
price: 149,
currency_symbol: '$',
},
})
.attr(
'tabs', ['id', 'host'], (id, host) => [
Factory.build(
'tab',
{
title: 'Course',
priority: 0,
slug: 'courseware',
type: 'courseware',
},
{
courseId: id,
host,
path: 'course/',
},
),
Factory.build(
'tab',
{
title: 'Discussion',
priority: 1,
slug: 'discussion',
type: 'discussion',
},
{
courseId: id,
host,
path: 'discussion/forum/',
},
),
Factory.build(
'tab',
{
title: 'Wiki',
priority: 2,
slug: 'wiki',
type: 'wiki',
},
{
courseId: id,
host,
path: 'course_wiki',
},
),
Factory.build(
'tab',
{
title: 'Progress',
priority: 3,
slug: 'progress',
type: 'progress',
},
{
courseId: id,
host,
path: 'progress',
},
),
Factory.build(
'tab',
{
title: 'Instructor',
priority: 4,
slug: 'instructor',
type: 'instructor',
},
{
courseId: id,
host,
path: 'instructor',
},
),
Factory.build(
'tab',
{
title: 'Dates',
priority: 5,
slug: 'dates',
type: 'dates',
},
{
courseId: id,
host,
path: 'dates',
},
),
],
);

View File

@@ -0,0 +1,222 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
// Sample data helpful when developing & testing, to see a variety of configurations.
// This set of data is not realistic (mix of having access and not), but it
// is intended to demonstrate many UI results.
Factory.define('datesTabData')
.attrs({
dates_banner_info: {
content_type_gating_enabled: false,
missed_gated_content: false,
missed_deadlines: false,
verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5',
},
course_date_blocks: [
{
date: '2020-05-01T17:59:41Z',
date_type: 'course-start-date',
description: '',
learner_has_access: true,
link: '',
title: 'Course Starts',
extra_info: null,
},
{
assignment_type: 'Homework',
complete: true,
date: '2020-05-04T02:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
title: 'Multi Badges Completed',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2020-05-05T02:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
title: 'Multi Badges Past Due',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2020-05-27T02:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'Both Past Due 1',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2020-05-27T02:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'Both Past Due 2',
extra_info: null,
},
{
assignment_type: 'Homework',
complete: true,
date: '2020-05-28T08:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'One Completed/Due 1',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2020-05-28T08:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'One Completed/Due 2',
extra_info: null,
},
{
assignment_type: 'Homework',
complete: true,
date: '2020-05-29T08:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'Both Completed 1',
extra_info: null,
},
{
assignment_type: 'Homework',
complete: true,
date: '2020-05-29T08:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'Both Completed 2',
extra_info: null,
},
{
date: '2020-06-16T17:59:40.942669Z',
date_type: 'verified-upgrade-deadline',
description: "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
learner_has_access: true,
link: 'https://example.com/',
title: 'Upgrade to Verified Certificate',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2030-08-17T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: false,
link: 'https://example.com/',
title: 'One Verified 1',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2030-08-17T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'One Verified 2',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2030-08-17T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'ORA Verified 2',
extra_info: "ORA Dates are set by the instructor, and can't be changed",
},
{
assignment_type: 'Homework',
date: '2030-08-18T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: false,
link: 'https://example.com/',
title: 'Both Verified 1',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2030-08-18T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: false,
link: 'https://example.com/',
title: 'Both Verified 2',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2030-08-19T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
title: 'One Unreleased 1',
},
{
assignment_type: 'Homework',
date: '2030-08-19T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'One Unreleased 2',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2030-08-20T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
title: 'Both Unreleased 1',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2030-08-20T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
title: 'Both Unreleased 2',
extra_info: null,
},
{
date: '2030-08-23T00:00:00Z',
date_type: 'course-end-date',
description: '',
learner_has_access: true,
link: '',
title: 'Course Ends',
extra_info: null,
},
{
date: '2030-09-01T00:00:00Z',
date_type: 'verification-deadline-date',
description: 'You must successfully complete verification before this date to qualify for a Verified Certificate.',
learner_has_access: false,
link: 'https://example.com/',
title: 'Verification Deadline',
extra_info: null,
},
],
has_ended: false,
learner_is_full_access: true,
});

View File

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

View File

@@ -0,0 +1,66 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import { buildMinimalCourseBlocks } from '../../../shared/data/__factories__/courseBlocks.factory';
Factory.define('outlineTabData')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
.option('host', 'http://localhost:18000')
.option('date_blocks', [])
.attr('course_blocks', ['courseId'], courseId => {
const { courseBlocks } = buildMinimalCourseBlocks(courseId);
return {
blocks: courseBlocks.blocks,
};
})
.attr('dates_widget', ['date_blocks'], (dateBlocks) => ({
course_date_blocks: dateBlocks,
}))
.attr('resume_course', ['host', 'courseId'], (host, courseId) => ({
has_visited_course: false,
url: `${host}/courses/${courseId}/jump_to/block-v1:edX+Test+Block@12345abcde`,
}))
.attr('verified_mode', ['host'], (host) => ({
access_expiration_date: '2050-01-01T12:00:00',
currency: 'USD',
currency_symbol: '$',
price: 149,
sku: 'ABCD1234',
upgrade_url: `${host}/dashboard`,
}))
.attrs({
has_scheduled_content: null,
access_expiration: null,
can_show_upgrade_sock: false,
cert_data: {
cert_status: null,
cert_web_view_url: null,
certificate_available_date: null,
download_url: null,
},
course_goals: {
goal_options: [],
selected_goal: null,
weekly_learning_goal_enabled: false,
days_per_week: null,
subscribed_to_reminders: null,
},
course_tools: [
{
analytics_id: 'edx.bookmarks',
title: 'Bookmarks',
url: 'https://example.com/bookmarks',
},
],
dates_banner_info: {
content_type_gating_enabled: false,
missed_gated_content: false,
missed_deadlines: false,
},
enroll_alert: {
can_enroll: true,
extra_text: 'Contact the administrator.',
},
handouts_html: '<ul><li>Handout 1</li></ul>',
offer: null,
welcome_message_html: '<p>Welcome to this course!</p>',
});

View File

@@ -0,0 +1,83 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
// Sample data helpful when developing & testing, to see a variety of configurations.
// This set of data may not be realistic, but it is intended to demonstrate many UI results.
Factory.define('progressTabData')
.attrs({
access_expiration: null,
end: '3027-03-31T00:00:00Z',
certificate_data: {},
completion_summary: {
complete_count: 1,
incomplete_count: 1,
locked_count: 0,
},
course_grade: {
letter_grade: 'pass',
percent: 1,
is_passing: true,
},
credit_course_requirements: null,
section_scores: [
{
display_name: 'First section',
subsections: [
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 0,
num_points_possible: 3,
percent_graded: 0.0,
problem_scores: [{ earned: 0, possible: 1 }, { earned: 0, possible: 1 }, { earned: 0, possible: 1 }],
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
{
display_name: 'Second section',
subsections: [
{
assignment_type: 'Homework',
display_name: 'Second subsection',
has_graded_assignment: true,
num_points_earned: 1,
num_points_possible: 1,
percent_graded: 1.0,
problem_scores: [{ earned: 1, possible: 1 }],
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
},
],
},
],
enrollment_mode: 'audit',
grading_policy: {
assignment_policies: [
{
num_droppable: 1,
num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
has_scheduled_content: false,
studio_url: 'http://studio.edx.org/settings/grading/course-v1:edX+Test+run',
user_has_passing_grade: false,
verification_data: {
link: null,
status: 'none',
status_date: null,
},
verified_mode: null,
});

View File

@@ -0,0 +1,25 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('upgradeNotificationData')
.option('host', 'http://localhost:18000')
.option('dateBlocks', [])
.option('offer', null)
.option('userTimezone', null)
.option('contentTypeGatingEnabled', false)
.attr('courseId', 'course-v1:edX+DemoX+Demo_Course')
.attr('upsellPageName', 'test')
.attr('verifiedMode', ['host'], (host) => ({
access_expiration_date: '2050-01-01T12:00:00',
currency: 'USD',
currencySymbol: '$',
price: 149,
sku: 'ABCD1234',
upgradeUrl: `${host}/dashboard`,
}))
.attr('org', 'edX')
.attrs({
accessExpiration: {
expiration_date: '1950-07-13T02:04:49.040006Z',
},
})
.attr('timeOffsetMillis', 0);

View File

@@ -0,0 +1,722 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
},
"courseware": Object {
"courseId": null,
"courseStatus": "loading",
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true,
"canViewCertificate": true,
"celebrations": null,
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course",
"isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [
Object {
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": Object {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
"dates": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"courseDateBlocks": Array [
Object {
"date": "2020-05-01T17:59:41Z",
"dateType": "course-start-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "",
"title": "Course Starts",
},
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-04T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Multi Badges Completed",
},
Object {
"assignmentType": "Homework",
"date": "2020-05-05T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Multi Badges Past Due",
},
Object {
"assignmentType": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Past Due 1",
},
Object {
"assignmentType": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Past Due 2",
},
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-28T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Completed/Due 1",
},
Object {
"assignmentType": "Homework",
"date": "2020-05-28T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Completed/Due 2",
},
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Completed 1",
},
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Completed 2",
},
Object {
"date": "2020-06-16T17:59:40.942669Z",
"dateType": "verified-upgrade-deadline",
"description": "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Upgrade to Verified Certificate",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "One Verified 1",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Verified 2",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": "ORA Dates are set by the instructor, and can't be changed",
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "ORA Verified 2",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "Both Verified 1",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "Both Verified 2",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"learnerHasAccess": true,
"title": "One Unreleased 1",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Unreleased 2",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Both Unreleased 1",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Both Unreleased 2",
},
Object {
"date": "2030-08-23T00:00:00Z",
"dateType": "course-end-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "",
"title": "Course Ends",
},
Object {
"date": "2030-09-01T00:00:00Z",
"dateType": "verification-deadline-date",
"description": "You must successfully complete verification before this date to qualify for a Verified Certificate.",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "Verification Deadline",
},
],
"datesBannerInfo": Object {
"contentTypeGatingEnabled": false,
"missedDeadlines": false,
"missedGatedContent": false,
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
"hasEnded": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"learnerIsFullAccess": true,
},
},
},
"recommendations": Object {
"recommendationsStatus": "loading",
},
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
}
`;
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
},
"courseware": Object {
"courseId": null,
"courseStatus": "loading",
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true,
"canViewCertificate": true,
"celebrations": null,
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course",
"isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [
Object {
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": Object {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
"outline": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"accessExpiration": null,
"canShowUpgradeSock": false,
"certData": Object {
"certStatus": null,
"certWebViewUrl": null,
"certificateAvailableDate": null,
"downloadUrl": null,
},
"courseBlocks": Object {
"courses": Object {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"sectionIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
],
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
},
},
"sections": Object {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
"complete": false,
"courseId": "course-v1:edX+DemoX+Demo_Course",
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"resumeBlock": false,
"sequenceIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
],
"title": "Title of Section",
},
},
"sequences": Object {
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
"complete": false,
"description": null,
"due": null,
"effortActivities": 2,
"effortTime": 15,
"icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"legacyWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1?experience=legacy",
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"showLink": true,
"title": "Title of Sequence",
},
},
},
"courseGoals": Object {
"daysPerWeek": null,
"goalOptions": Array [],
"selectedGoal": null,
"subscribedToReminders": null,
"weeklyLearningGoalEnabled": false,
},
"courseTools": Array [
Object {
"analyticsId": "edx.bookmarks",
"title": "Bookmarks",
"url": "https://example.com/bookmarks",
},
],
"datesBannerInfo": Object {
"contentTypeGatingEnabled": false,
"missedDeadlines": false,
"missedGatedContent": false,
},
"datesWidget": Object {
"courseDateBlocks": Array [],
},
"enableProctoredExams": undefined,
"enrollAlert": Object {
"canEnroll": true,
"extraText": "Contact the administrator.",
},
"enrollmentMode": undefined,
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
"hasEnded": undefined,
"hasScheduledContent": null,
"id": "course-v1:edX+DemoX+Demo_Course",
"offer": null,
"resumeCourse": Object {
"hasVisitedCourse": false,
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
},
"timeOffsetMillis": 0,
"userHasPassingGrade": undefined,
"verifiedMode": Object {
"accessExpirationDate": "2050-01-01T12:00:00",
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "ABCD1234",
"upgradeUrl": "http://localhost:18000/dashboard",
},
"welcomeMessageHtml": "<p>Welcome to this course!</p>",
},
},
},
"recommendations": Object {
"recommendationsStatus": "loading",
},
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
}
`;
exports[`Data layer integration tests Test fetchProgressTab Should fetch, normalize, and save metadata 1`] = `
Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
},
"courseware": Object {
"courseId": null,
"courseStatus": "loading",
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true,
"canViewCertificate": true,
"celebrations": null,
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course",
"isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [
Object {
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": Object {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
"progress": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"accessExpiration": null,
"certificateData": Object {},
"completionSummary": Object {
"completeCount": 1,
"incompleteCount": 1,
"lockedCount": 0,
},
"courseGrade": Object {
"isPassing": true,
"letterGrade": "pass",
"percent": 1,
},
"courseId": "course-v1:edX+DemoX+Demo_Course",
"creditCourseRequirements": null,
"end": "3027-03-31T00:00:00Z",
"enrollmentMode": "audit",
"gradesFeatureIsFullyLocked": false,
"gradesFeatureIsPartiallyLocked": false,
"gradingPolicy": Object {
"assignmentPolicies": Array [
Object {
"averageGrade": "1.00",
"numDroppable": 1,
"shortLabel": "HW",
"type": "Homework",
"weight": 1,
"weightedGrade": 1,
},
],
"gradeRange": Object {
"pass": 0.75,
},
},
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"sectionScores": Array [
Object {
"displayName": "First section",
"subsections": Array [
Object {
"assignmentType": "Homework",
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
"displayName": "First subsection",
"hasGradedAssignment": true,
"learnerHasAccess": true,
"numPointsEarned": 0,
"numPointsPossible": 3,
"percentGraded": 0,
"problemScores": Array [
Object {
"earned": 0,
"possible": 1,
},
Object {
"earned": 0,
"possible": 1,
},
Object {
"earned": 0,
"possible": 1,
},
],
"showCorrectness": "always",
"showGrades": true,
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection",
},
],
},
Object {
"displayName": "Second section",
"subsections": Array [
Object {
"assignmentType": "Homework",
"displayName": "Second subsection",
"hasGradedAssignment": true,
"numPointsEarned": 1,
"numPointsPossible": 1,
"percentGraded": 1,
"problemScores": Array [
Object {
"earned": 1,
"possible": 1,
},
],
"showCorrectness": "always",
"showGrades": true,
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection",
},
],
},
],
"studioUrl": "http://studio.edx.org/settings/grading/course-v1:edX+Test+run",
"userHasPassingGrade": false,
"verificationData": Object {
"link": null,
"status": "none",
"statusDate": null,
},
"verifiedMode": null,
},
},
},
"recommendations": Object {
"recommendationsStatus": "loading",
},
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
}
`;

427
src/course-home/data/api.js Normal file
View File

@@ -0,0 +1,427 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logInfo } from '@edx/frontend-platform/logging';
import { appendBrowserTimezoneToUrl } from '../../utils';
const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => {
let dropCount = numDroppable;
// Drop the lowest grades
while (dropCount && points.length >= dropCount) {
const lowestScore = Math.min(...points);
const lowestScoreIndex = points.indexOf(lowestScore);
points.splice(lowestScoreIndex, 1);
dropCount--;
}
let averageGrade = 0;
let weightedGrade = 0;
if (points.length) {
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
// exists in edx-platform.
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(2);
weightedGrade = averageGrade * assignmentWeight;
}
return { averageGrade, weightedGrade };
};
function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
const gradeByAssignmentType = {};
assignmentPolicies.forEach(assignment => {
// Create an array with the number of total assignments and set the scores to 0
// as placeholders for assignments that have not yet been released
gradeByAssignmentType[assignment.type] = {
grades: Array(assignment.numTotal).fill(0),
numAssignmentsCreated: 0,
numTotalExpectedAssignments: assignment.numTotal,
};
});
sectionScores.forEach((chapter) => {
chapter.subsections.forEach((subsection) => {
if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) {
return;
}
const {
assignmentType,
numPointsEarned,
numPointsPossible,
} = subsection;
// If a subsection's assignment type does not match an assignment policy in Studio,
// we won't be able to include it in this accumulation of grades by assignment type.
// This may happen if a course author has removed/renamed an assignment policy in Studio and
// neglected to update the subsection's of that assignment type
if (!gradeByAssignmentType[assignmentType]) {
return;
}
let {
numAssignmentsCreated,
} = gradeByAssignmentType[assignmentType];
numAssignmentsCreated++;
if (numAssignmentsCreated <= gradeByAssignmentType[assignmentType].numTotalExpectedAssignments) {
// Remove a placeholder grade so long as the number of recorded created assignments is less than the number
// of expected assignments
gradeByAssignmentType[assignmentType].grades.shift();
}
// Add the graded assignment to the list
gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
// Record the created assignment
gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated;
});
});
return assignmentPolicies.map((assignment) => {
const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades(
gradeByAssignmentType[assignment.type].grades,
assignment.weight,
assignment.numDroppable,
);
return {
averageGrade,
numDroppable: assignment.numDroppable,
shortLabel: assignment.shortLabel,
type: assignment.type,
weight: assignment.weight,
weightedGrade,
};
});
}
/**
* Tweak the metadata for consistency
* @param metadata the data to normalize
* @param rootSlug either 'courseware' or 'outline' depending on the context
* @returns {Object} The normalized metadata
*/
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
const data = camelCaseObject(metadata);
return {
...data,
tabs: data.tabs.map(tab => ({
// The API uses "courseware" as a slug for both courseware and the outline tab.
// If needed, we switch it to "outline" here for
// use within the MFE to differentiate between course home and courseware.
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId,
title: tab.title,
url: tab.url,
})),
isMasquerading: data.originalUserIsStaff && !data.isStaff,
};
}
export function normalizeOutlineBlocks(courseId, blocks) {
const models = {
courses: {},
sections: {},
sequences: {},
};
Object.values(blocks).forEach(block => {
switch (block.type) {
case 'course':
models.courses[block.id] = {
id: courseId,
title: block.display_name,
sectionIds: block.children || [],
hasScheduledContent: block.has_scheduled_content,
};
break;
case 'chapter':
models.sections[block.id] = {
complete: block.complete,
id: block.id,
title: block.display_name,
resumeBlock: block.resume_block,
sequenceIds: block.children || [],
};
break;
case 'sequential':
models.sequences[block.id] = {
complete: block.complete,
description: block.description,
due: block.due,
effortActivities: block.effort_activities,
effortTime: block.effort_time,
icon: block.icon,
id: block.id,
legacyWebUrl: block.legacy_web_url,
// The presence of an legacy URL for the sequence indicates that we want this
// sequence to be a clickable link in the outline (even though, if the new
// courseware experience is active, we will ignore `legacyWebUrl` and build a
// link to the MFE ourselves).
showLink: !!block.legacy_web_url,
title: block.display_name,
};
break;
default:
logInfo(`Unexpected course block type: ${block.type} with ID ${block.id}. Expected block types are course, chapter, and sequential.`);
}
});
// Next go through each list and use their child lists to decorate those children with a
// reference back to their parent.
Object.values(models.courses).forEach(course => {
if (Array.isArray(course.sectionIds)) {
course.sectionIds.forEach(sectionId => {
const section = models.sections[sectionId];
section.courseId = course.id;
});
}
});
Object.values(models.sections).forEach(section => {
if (Array.isArray(section.sequenceIds)) {
section.sequenceIds.forEach(sequenceId => {
if (sequenceId in models.sequences) {
models.sequences[sequenceId].sectionId = section.id;
} else {
logInfo(`Section ${section.id} has child block ${sequenceId}, but that block is not in the list of sequences.`);
}
});
}
});
return models;
}
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
let url = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
url = appendBrowserTimezoneToUrl(url);
const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeCourseHomeCourseMetadata(data, rootSlug);
}
// For debugging purposes, you might like to see a fully loaded dates tab.
// Just uncomment the next few lines and the immediate 'return' in the function below
// import { Factory } from 'rosie';
// import './__factories__';
export async function getDatesTabData(courseId) {
// return camelCaseObject(Factory.build('datesTabData'));
const url = `${getConfig().LMS_BASE_URL}/api/course_home/dates/${courseId}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
return {};
}
if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
// courseAccess in the metadata call, so just ignore this status for now.
return {};
}
throw error;
}
}
export async function getProgressTabData(courseId, targetUserId) {
let url = `${getConfig().LMS_BASE_URL}/api/course_home/progress/${courseId}`;
// If targetUserId is passed in, we will get the progress page data
// for the user with the provided id, rather than the requesting user.
if (targetUserId) {
url += `/${targetUserId}/`;
}
try {
const { data } = await getAuthenticatedHttpClient().get(url);
const camelCasedData = camelCaseObject(data);
camelCasedData.gradingPolicy.assignmentPolicies = normalizeAssignmentPolicies(
camelCasedData.gradingPolicy.assignmentPolicies,
camelCasedData.sectionScores,
);
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
// in order to preserve a course team's desired grade formatting.
camelCasedData.gradingPolicy.gradeRange = data.grading_policy.grade_range;
camelCasedData.gradesFeatureIsFullyLocked = camelCasedData.completionSummary.lockedCount > 0;
camelCasedData.gradesFeatureIsPartiallyLocked = false;
if (camelCasedData.gradesFeatureIsFullyLocked) {
camelCasedData.sectionScores.forEach((chapter) => {
chapter.subsections.forEach((subsection) => {
// If something is eligible to be gated by content type gating and would show up on the progress page
if (subsection.assignmentType !== null && subsection.hasGradedAssignment && subsection.showGrades
&& (subsection.numPointsPossible > 0 || subsection.numPointsEarned > 0)) {
// but the learner still has access to it, then we are in a partially locked, rather than fully locked state
// since the learner has access to some (but not all) content that would normally be locked
if (subsection.learnerHasAccess) {
camelCasedData.gradesFeatureIsPartiallyLocked = true;
camelCasedData.gradesFeatureIsFullyLocked = false;
}
}
});
});
}
return camelCasedData;
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
return {};
}
if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
// courseAccess in the metadata call, so just ignore this status for now.
return {};
}
throw error;
}
}
export async function getProctoringInfoData(courseId, username) {
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
if (username) {
url += `&username=${encodeURIComponent(username)}`;
}
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return data;
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
return {};
}
throw error;
}
}
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
let timeOffsetMillis = 0;
if (headerDate !== undefined) {
const headerTime = Date.parse(headerDate);
const roundTripMillis = requestTime - responseTime;
const localTime = responseTime - (roundTripMillis / 2); // Roughly compensate for transit time
timeOffsetMillis = headerTime - localTime;
}
return timeOffsetMillis;
}
export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
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) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
return {};
}
throw error;
}
const {
data,
headers,
} = tabData;
const accessExpiration = camelCaseObject(data.access_expiration);
const canShowUpgradeSock = data.can_show_upgrade_sock;
const certData = camelCaseObject(data.cert_data);
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
const courseGoals = camelCaseObject(data.course_goals);
const courseTools = camelCaseObject(data.course_tools);
const datesBannerInfo = camelCaseObject(data.dates_banner_info);
const datesWidget = camelCaseObject(data.dates_widget);
const enableProctoredExams = data.enable_proctored_exams;
const enrollAlert = camelCaseObject(data.enroll_alert);
const enrollmentMode = data.enrollment_mode;
const handoutsHtml = data.handouts_html;
const hasScheduledContent = data.has_scheduled_content;
const hasEnded = data.has_ended;
const offer = camelCaseObject(data.offer);
const resumeCourse = camelCaseObject(data.resume_course);
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
const userHasPassingGrade = data.user_has_passing_grade;
const verifiedMode = camelCaseObject(data.verified_mode);
const welcomeMessageHtml = data.welcome_message_html;
return {
accessExpiration,
canShowUpgradeSock,
certData,
courseBlocks,
courseGoals,
courseTools,
datesBannerInfo,
datesWidget,
enrollAlert,
enrollmentMode,
enableProctoredExams,
handoutsHtml,
hasScheduledContent,
hasEnded,
offer,
resumeCourse,
timeOffsetMillis, // This should move to a global time correction reference
userHasPassingGrade,
verifiedMode,
welcomeMessageHtml,
};
}
export async function postCourseDeadlines(courseId, model) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`);
return getAuthenticatedHttpClient().post(url.href, {
course_key: courseId,
research_event_data: { location: `${model}-tab` },
});
}
export async function deprecatedPostCourseGoals(courseId, goalKey) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`);
return getAuthenticatedHttpClient().post(url.href, { course_id: courseId, goal_key: goalKey });
}
export async function postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`);
return getAuthenticatedHttpClient().post(url.href, {
course_id: courseId,
days_per_week: daysPerWeek,
subscribed_to_reminders: subscribedToReminders,
});
}
export async function postDismissWelcomeMessage(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`);
await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
}
export async function postRequestCert(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/generate_user_cert`);
await getAuthenticatedHttpClient().post(url.href);
}
export async function executePostFromPostEvent(postData, researchEventData) {
const url = new URL(postData.url);
return getAuthenticatedHttpClient().post(url.href, {
course_key: postData.bodyParams.courseId,
research_event_data: researchEventData,
});
}
export async function unsubscribeFromCourseGoal(token) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/unsubscribe_from_course_goal/${token}`);
return getAuthenticatedHttpClient().post(url.href)
.then(res => camelCaseObject(res));
}

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

@@ -0,0 +1,10 @@
export {
fetchDatesTab,
fetchOutlineTab,
fetchProgressTab,
resetDeadlines,
deprecatedSaveCourseGoal,
saveWeeklyLearningGoal,
} from './thunks';
export { reducer } from './slice';

View File

@@ -0,0 +1,223 @@
import { Pact, Matchers } from '@pact-foundation/pact';
import path from 'path';
import { mergeConfig, getConfig } from '@edx/frontend-platform';
import {
getCourseHomeCourseMetadata,
getDatesTabData,
} from '../api';
import { initializeMockApp } from '../../../setupTest';
import {
courseId, dateRegex, opaqueKeysRegex, dateTypeRegex,
} from '../../../pacts/constants';
const {
somethingLike: like, term, boolean, string, eachLike,
} = Matchers;
const provider = new Pact({
consumer: 'frontend-app-learning',
provider: 'lms',
log: path.resolve(process.cwd(), 'src/course-home/data/pact-tests/logs', 'pact.log'),
dir: path.resolve(process.cwd(), 'src/pacts'),
pactfileWriteMode: 'merge',
logLevel: 'DEBUG',
cors: true,
});
describe('Course Home Service', () => {
beforeAll(async () => {
initializeMockApp();
await provider
.setup()
.then((options) => mergeConfig({
LMS_BASE_URL: `http://localhost:${options.port}`,
}, 'Custom app config for pact tests'));
});
afterEach(() => provider.verify());
afterAll(() => provider.finalize());
describe('When a request to fetch tab is made', () => {
it('returns tab data for a course_id', async () => {
await provider.addInteraction({
state: `Tab data exists for course_id ${courseId}`,
uponReceiving: 'a request to fetch tab',
withRequest: {
method: 'GET',
path: `/api/course_home/course_metadata/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
can_show_upgrade_sock: boolean(false),
verified_mode: like({
access_expiration_date: null,
currency: 'USD',
currency_symbol: '$',
price: 149,
sku: '8CF08E5',
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}),
can_load_courseware: boolean(true),
celebrations: like({
first_section: false,
streak_length_to_celebrate: null,
streak_discount_enabled: false,
}),
course_access: {
has_access: boolean(true),
error_code: null,
developer_message: null,
user_message: null,
additional_context_user_message: null,
user_fragment: null,
},
course_id: term({
generate: 'course-v1:edX+DemoX+Demo_Course',
matcher: opaqueKeysRegex,
}),
is_enrolled: boolean(true),
is_self_paced: boolean(false),
is_staff: boolean(true),
number: string('DemoX'),
org: string('edX'),
original_user_is_staff: boolean(true),
start: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
tabs: eachLike({
tab_id: 'courseware',
title: 'Course',
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
}),
title: string('Demonstration Course'),
username: string('edx'),
},
},
});
const normalizedTabData = {
canShowUpgradeSock: false,
verifiedMode: {
accessExpirationDate: null,
currency: 'USD',
currencySymbol: '$',
price: 149,
sku: '8CF08E5',
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
},
canLoadCourseware: true,
celebrations: {
firstSection: false,
streakLengthToCelebrate: null,
streakDiscountEnabled: false,
},
courseAccess: {
hasAccess: true,
errorCode: null,
developerMessage: null,
userMessage: null,
additionalContextUserMessage: null,
userFragment: null,
},
courseId: 'course-v1:edX+DemoX+Demo_Course',
isEnrolled: true,
isMasquerading: false,
isSelfPaced: false,
isStaff: true,
number: 'DemoX',
org: 'edX',
originalUserIsStaff: true,
start: '2013-02-05T05:00:00Z',
tabs: [
{
slug: 'outline',
title: 'Course',
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
},
],
title: 'Demonstration Course',
username: 'edx',
};
const response = await getCourseHomeCourseMetadata(courseId, 'outline');
expect(response).toBeTruthy();
expect(response).toEqual(normalizedTabData);
});
});
describe('When a request to fetch dates tab is made', () => {
it('returns course date blocks for a course_id', async () => {
await provider.addInteraction({
state: `course date blocks exist for course_id ${courseId}`,
uponReceiving: 'a request to fetch dates tab',
withRequest: {
method: 'GET',
path: `/api/course_home/dates/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
dates_banner_info: like({
missed_deadlines: false,
content_type_gating_enabled: false,
missed_gated_content: false,
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}),
course_date_blocks: eachLike({
assignment_type: null,
complete: null,
date: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
date_type: term({
generate: 'verified-upgrade-deadline',
matcher: dateTypeRegex,
}),
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
learner_has_access: true,
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
link_text: 'Upgrade to Verified Certificate',
title: 'Verification Upgrade Deadline',
extra_info: null,
first_component_block_id: '',
}),
has_ended: boolean(false),
learner_is_full_access: boolean(true),
user_timezone: null,
},
},
});
const camelCaseResponse = {
datesBannerInfo: {
missedDeadlines: false,
contentTypeGatingEnabled: false,
missedGatedContent: false,
verifiedUpgradeLink: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
},
courseDateBlocks: [
{
assignmentType: null,
complete: null,
date: '2013-02-05T05:00:00Z',
dateType: 'verified-upgrade-deadline',
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
learnerHasAccess: true,
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
linkText: 'Upgrade to Verified Certificate',
title: 'Verification Upgrade Deadline',
extraInfo: null,
firstComponentBlockId: '',
},
],
hasEnded: false,
learnerIsFullAccess: true,
userTimezone: null,
};
const response = await getDatesTabData(courseId);
expect(response).toBeTruthy();
expect(response).toEqual(camelCaseResponse);
});
});
});

View File

@@ -0,0 +1,176 @@
import { Factory } from 'rosie';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import * as thunks from './thunks';
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
import { initializeMockApp } from '../../setupTest';
import initializeStore from '../../store';
const { loggingService } = initializeMockApp();
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Data layer integration tests', () => {
const courseHomeMetadata = Factory.build('courseHomeMetadata');
const { id: courseId } = courseHomeMetadata;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
let store;
beforeEach(() => {
axiosMock.reset();
loggingService.logError.mockReset();
store = initializeStore();
});
describe('Test fetchDatesTab', () => {
const datesBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dates`;
it('Should fail to fetch if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).networkError();
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});
it('Should fetch, normalize, and save metadata', async () => {
const datesTabData = Factory.build('datesTabData');
const datesUrl = `${datesBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(datesUrl).reply(200, datesTabData);
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot();
});
});
describe('Test fetchOutlineTab', () => {
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`;
it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});
it('Should fetch, normalize, and save metadata', async () => {
const outlineTabData = Factory.build('outlineTabData', { courseId });
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot();
});
});
describe('Test fetchProgressTab', () => {
const progressBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/progress`;
it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(`${progressBaseUrl}/${courseId}`).networkError();
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});
it('Should fetch, normalize, and save metadata', async () => {
const progressTabData = Factory.build('progressTabData', { courseId });
const progressUrl = `${progressBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(progressUrl).reply(200, progressTabData);
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot();
});
it('Should handle the url including a targetUserId', async () => {
const progressTabData = Factory.build('progressTabData', { courseId });
const targetUserId = 2;
const progressUrl = `${progressBaseUrl}/${courseId}/${targetUserId}/`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(progressUrl).reply(200, progressTabData);
await executeThunk(thunks.fetchProgressTab(courseId, 2), store.dispatch);
const state = store.getState();
expect(state.courseHome.targetUserId).toEqual(2);
});
});
describe('Test saveCourseGoal', () => {
it('Should save course goal', async () => {
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
axiosMock.onPost(goalUrl).reply(200, {});
await thunks.deprecatedSaveCourseGoal(courseId, 'unsure');
expect(axiosMock.history.post[0].url).toEqual(goalUrl);
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}","goal_key":"unsure"}`);
});
});
describe('Test resetDeadlines', () => {
it('Should reset course deadlines', async () => {
const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
const model = 'dates';
axiosMock.onPost(resetUrl).reply(201, {});
const getTabDataMock = jest.fn(() => ({
type: 'MOCK_ACTION',
}));
await executeThunk(thunks.resetDeadlines(courseId, model, getTabDataMock), store.dispatch);
expect(axiosMock.history.post[0].url).toEqual(resetUrl);
expect(axiosMock.history.post[0].data).toEqual(`{"course_key":"${courseId}","research_event_data":{"location":"dates-tab"}}`);
expect(getTabDataMock).toHaveBeenCalledWith(courseId);
});
});
describe('Test dismissWelcomeMessage', () => {
it('Should dismiss welcome message', async () => {
const dismissUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`;
axiosMock.onPost(dismissUrl).reply(201);
await executeThunk(thunks.dismissWelcomeMessage(courseId), store.dispatch);
expect(axiosMock.history.post[0].url).toEqual(dismissUrl);
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}"}`);
});
});
});

View File

@@ -0,0 +1,64 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const LOADING = 'loading';
export const LOADED = 'loaded';
export const FAILED = 'failed';
export const DENIED = 'denied';
const slice = createSlice({
name: 'course-home',
initialState: {
courseStatus: 'loading',
courseId: null,
proctoringPanelStatus: 'loading',
toastBodyText: null,
toastBodyLink: null,
toastHeader: '',
},
reducers: {
fetchProctoringInfoResolved: (state) => {
state.proctoringPanelStatus = LOADED;
},
fetchTabDenied: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = DENIED;
},
fetchTabFailure: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = FAILED;
},
fetchTabRequest: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = LOADING;
},
fetchTabSuccess: (state, { payload }) => {
state.courseId = payload.courseId;
state.targetUserId = payload.targetUserId;
state.courseStatus = LOADED;
},
setCallToActionToast: (state, { payload }) => {
const {
header,
link,
linkText,
} = payload;
state.toastBodyLink = link;
state.toastBodyText = linkText;
state.toastHeader = header;
},
},
});
export const {
fetchProctoringInfoResolved,
fetchTabDenied,
fetchTabFailure,
fetchTabRequest,
fetchTabSuccess,
setCallToActionToast,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,140 @@
import { logError } from '@edx/frontend-platform/logging';
import { camelCaseObject } from '@edx/frontend-platform';
import {
executePostFromPostEvent,
getCourseHomeCourseMetadata,
getDatesTabData,
getOutlineTabData,
getProgressTabData,
postCourseDeadlines,
deprecatedPostCourseGoals,
postWeeklyLearningGoal,
postDismissWelcomeMessage,
postRequestCert,
} from './api';
import {
addModel,
} from '../../generic/model-store';
import {
fetchTabDenied,
fetchTabFailure,
fetchTabRequest,
fetchTabSuccess,
setCallToActionToast,
} from './slice';
const eventTypes = {
POST_EVENT: 'post_event',
};
export function fetchTab(courseId, tab, getTabData, targetUserId) {
return async (dispatch) => {
dispatch(fetchTabRequest({ courseId }));
Promise.allSettled([
getCourseHomeCourseMetadata(courseId, 'outline'),
getTabData(courseId, targetUserId),
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
const fetchedTabData = tabDataResult.status === 'fulfilled';
if (fetchedCourseHomeCourseMetadata) {
dispatch(addModel({
modelType: 'courseHomeMeta',
model: {
id: courseId,
...courseHomeCourseMetadataResult.value,
},
}));
} else {
logError(courseHomeCourseMetadataResult.reason);
}
if (fetchedTabData) {
dispatch(addModel({
modelType: tab,
model: {
id: courseId,
...tabDataResult.value,
},
}));
} else {
logError(tabDataResult.reason);
}
// Disable the access-denied path for now - it caused a regression
if (fetchedCourseHomeCourseMetadata && !courseHomeCourseMetadataResult.value.courseAccess.hasAccess) {
dispatch(fetchTabDenied({ courseId }));
} else if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
dispatch(fetchTabSuccess({ courseId, targetUserId }));
} else {
dispatch(fetchTabFailure({ courseId }));
}
});
};
}
export function fetchDatesTab(courseId) {
return fetchTab(courseId, 'dates', getDatesTabData);
}
export function fetchProgressTab(courseId, targetUserId) {
return fetchTab(courseId, 'progress', getProgressTabData, parseInt(targetUserId, 10) || targetUserId);
}
export function fetchOutlineTab(courseId) {
return fetchTab(courseId, 'outline', getOutlineTabData);
}
export function dismissWelcomeMessage(courseId) {
return async () => postDismissWelcomeMessage(courseId);
}
export function requestCert(courseId) {
return async () => postRequestCert(courseId);
}
export function resetDeadlines(courseId, model, getTabData) {
return async (dispatch) => {
postCourseDeadlines(courseId, model).then(response => {
const { data } = response;
const {
header,
link,
link_text: linkText,
} = data;
dispatch(getTabData(courseId));
dispatch(setCallToActionToast({ header, link, linkText }));
});
};
}
export async function deprecatedSaveCourseGoal(courseId, goalKey) {
return deprecatedPostCourseGoals(courseId, goalKey);
}
export async function saveWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders) {
return postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders);
}
export function processEvent(eventData, getTabData) {
return async (dispatch) => {
// Pulling this out early so the data doesn't get camelCased and is easier
// to use when it's passed to the backend
const { research_event_data: researchEventData } = eventData;
const event = camelCaseObject(eventData);
if (event.eventName === eventTypes.POST_EVENT) {
executePostFromPostEvent(event.postData, researchEventData).then(response => {
const { data } = response;
const {
header,
link,
link_text: linkText,
} = data;
dispatch(getTabData(event.postData.bodyParams.courseId));
dispatch(setCallToActionToast({ header, link, linkText }));
});
}
};
}

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import Timeline from './timeline/Timeline';
import { fetchDatesTab } from '../data';
import { useModel } from '../../generic/model-store';
/** [MM-P2P] Experiment */
import { initDatesMMP2P } from '../../experiments/mm-p2p';
import SuggestedScheduleHeader from '../suggested-schedule-messaging/SuggestedScheduleHeader';
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpgradeToCompleteAlert from '../suggested-schedule-messaging/UpgradeToCompleteAlert';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
function DatesTab({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
isSelfPaced,
org,
} = useModel('courseHomeMeta', courseId);
const {
courseDateBlocks,
} = useModel('dates', courseId);
/** [MM-P2P] Experiment */
const mmp2p = initDatesMMP2P(courseId);
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
const logUpgradeLinkClick = () => {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
org_key: org,
courserun_key: courseId,
linkCategory: 'personalized_learner_schedules',
linkName: 'dates_upgrade',
linkType: 'button',
pageName: 'dates_tab',
});
};
return (
<>
<div role="heading" aria-level="1" className="h2 my-3">
{intl.formatMessage(messages.title)}
</div>
{ /** [MM-P2P] Experiment */ }
{isSelfPaced && hasDeadlines && !mmp2p.state.isEnabled && (
<>
<ShiftDatesAlert model="dates" fetch={fetchDatesTab} />
<SuggestedScheduleHeader />
<UpgradeToCompleteAlert logUpgradeLinkClick={logUpgradeLinkClick} />
<UpgradeToShiftDatesAlert logUpgradeLinkClick={logUpgradeLinkClick} model="dates" />
</>
)}
<Timeline mmp2p={mmp2p} />
</>
);
}
DatesTab.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(DatesTab);

View File

@@ -0,0 +1,352 @@
import React from 'react';
import { Route } from 'react-router';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getConfig, history } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { waitForElementToBeRemoved } from '@testing-library/dom';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DatesTab from './DatesTab';
import { fetchDatesTab } from '../data';
import { fireEvent, initializeMockApp, waitFor } from '../../setupTest';
import initializeStore from '../../store';
import { TabContainer } from '../../tab-page';
import { appendBrowserTimezoneToUrl } from '../../utils';
import { UserMessagesProvider } from '../../generic/user-messages';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
describe('DatesTab', () => {
let axiosMock;
let store;
let component;
beforeEach(() => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Route path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</Route>
</UserMessagesProvider>
</AppProvider>
);
});
const datesTabData = Factory.build('datesTabData');
let courseMetadata = Factory.build('courseHomeMetadata', { user_timezone: 'America/New_York' });
const { id: courseId } = courseMetadata;
const datesUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dates/${courseId}`;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
function setMetadata(attributes, options) {
courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
}
// The dates tab is largely repetitive non-interactive static data. Thus it's a little tough to follow
// testing-library's advice around testing the way your user uses the site (i.e. can't find form elements by label or
// anything). Instead, we find elements by printed date (which is what the user sees) and data-testid. Which is
// better than assuming anything about how the surrounding elements are organized by div and span or whatever. And
// better than adding non-style class names.
// Hence the following getDay query helper.
async function getDay(date) {
const dateNode = await screen.findByText(date);
let parent = dateNode.parentElement;
while (parent) {
if (parent.dataset && parent.dataset.testid === 'dates-day') {
return {
day: parent,
header: within(parent).getByTestId('dates-header'),
items: within(parent).queryAllByTestId('dates-item'),
};
}
parent = parent.parentElement;
}
throw new Error('Did not find day container');
}
describe('when receiving a full set of dates data', () => {
beforeEach(() => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
axiosMock.onGet(datesUrl).reply(200, datesTabData);
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
render(component);
});
it('handles unreleased & complete', async () => {
const { header } = await getDay('Sun, May 3, 2020');
const badges = within(header).getAllByTestId('dates-badge');
expect(badges).toHaveLength(2);
expect(badges[0]).toHaveTextContent('Completed');
expect(badges[1]).toHaveTextContent('Not yet released');
});
it('handles unreleased & past due', async () => {
const { header } = await getDay('Mon, May 4, 2020');
const badges = within(header).getAllByTestId('dates-badge');
expect(badges).toHaveLength(2);
expect(badges[0]).toHaveTextContent('Past due');
expect(badges[1]).toHaveTextContent('Not yet released');
});
it('handles verified only', async () => {
const { day } = await getDay('Sun, Aug 18, 2030');
const badge = within(day).getByTestId('dates-badge');
expect(badge).toHaveTextContent('Verified only');
});
it('verified only has no link', async () => {
const { day } = await getDay('Sun, Aug 18, 2030');
expect(within(day).queryByRole('link')).toBeNull();
});
it('same status items have header badge', async () => {
const { day, header } = await getDay('Tue, May 26, 2020');
const badge = within(header).getByTestId('dates-badge');
expect(badge).toHaveTextContent('Past due'); // one header badge
expect(within(day).getAllByTestId('dates-badge')).toHaveLength(1); // no other badges
});
it('different status items have individual badges', async () => {
const { header, items } = await getDay('Thu, May 28, 2020');
const headerBadges = within(header).queryAllByTestId('dates-badge');
expect(headerBadges).toHaveLength(0); // no header badges
expect(items).toHaveLength(2);
expect(within(items[0]).getByTestId('dates-badge')).toHaveTextContent('Completed');
expect(within(items[1]).getByTestId('dates-badge')).toHaveTextContent('Past due');
});
it('shows extra info', async () => {
const { items } = await getDay('Sat, Aug 17, 2030');
expect(items).toHaveLength(3);
const tipIcon = within(items[2]).getByTestId('dates-extra-info');
const tipText = "ORA Dates are set by the instructor, and can't be changed";
expect(screen.queryByText(tipText)).toBeNull(); // tooltip does not start in DOM
userEvent.hover(tipIcon);
const tooltip = screen.getByText(tipText); // now it's there
userEvent.unhover(tipIcon);
await waitForElementToBeRemoved(tooltip); // and it's gone again
});
});
describe('Suggested schedule messaging', () => {
beforeEach(() => {
setMetadata({ is_self_paced: true, is_enrolled: true });
history.push(`/course/${courseId}/dates`);
});
it('renders SuggestedScheduleHeader', async () => {
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: false,
missedDeadlines: false,
missedGatedContent: false,
};
axiosMock.onGet(datesUrl).reply(200, datesTabData);
render(component);
await waitFor(() => expect(screen.getByText('Weve built a suggested schedule to help you stay on track. But dont worry—its flexible so you can learn at your own pace.')).toBeInTheDocument());
});
it('renders UpgradeToCompleteAlert', async () => {
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
missedDeadlines: false,
missedGatedContent: false,
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(datesUrl).reply(200, datesTabData);
render(component);
await waitFor(() => expect(screen.getByText('You are auditing this course, which means that you are unable to participate in graded assignments. To complete graded assignments as part of this course, you can upgrade today.')).toBeInTheDocument());
expect(screen.getByRole('button', { name: 'Upgrade now' })).toBeInTheDocument();
});
it('renders UpgradeToShiftDatesAlert', async () => {
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
missedDeadlines: true,
missedGatedContent: true,
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(datesUrl).reply(200, datesTabData);
render(component);
await waitFor(() => expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument());
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Dont worry—you wont lose any of the progress youve made when you shift your due dates.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument();
});
it('renders ShiftDatesAlert', async () => {
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
missedDeadlines: true,
missedGatedContent: false,
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(datesUrl).reply(200, datesTabData);
render(component);
await waitFor(() => expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument());
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Dont worry—you wont lose any of the progress youve made when you shift your due dates.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Shift due dates' })).toBeInTheDocument();
});
it('handles shift due dates click', async () => {
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
missedDeadlines: true,
missedGatedContent: false,
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(datesUrl).reply(200, datesTabData);
render(component);
// confirm "Shift due dates" button has rendered
await waitFor(() => expect(screen.getByRole('button', { name: 'Shift due dates' })).toBeInTheDocument());
// update response to reflect shifted dates
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
missedDeadlines: false,
missedGatedContent: false,
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
const resetDeadlinesData = {
header: "You've successfully shifted your dates!",
};
axiosMock.onPost(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`).reply(200, resetDeadlinesData);
// click "Shift due dates"
fireEvent.click(screen.getByRole('button', { name: 'Shift due dates' }));
// wait for page to reload & Toast to render
await waitFor(() => expect(screen.getByText("You've successfully shifted your dates!")).toBeInTheDocument());
// confirm "Shift due dates" button has not rendered
expect(screen.queryByRole('button', { name: 'Shift due dates' })).not.toBeInTheDocument();
});
it('sends analytics event onClick of upgrade button in UpgradeToCompleteAlert', async () => {
sendTrackEvent.mockClear();
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
missedDeadlines: false,
missedGatedContent: false,
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(datesUrl).reply(200, datesTabData);
render(component);
const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade now' }));
fireEvent.click(upgradeButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: 'personalized_learner_schedules',
linkName: 'dates_upgrade',
linkType: 'button',
pageName: 'dates_tab',
});
});
it('sends analytics event onClick of upgrade button in UpgradeToShiftDatesAlert', async () => {
sendTrackEvent.mockClear();
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
missedDeadlines: true,
missedGatedContent: true,
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(datesUrl).reply(200, datesTabData);
render(component);
const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade to shift due dates' }));
fireEvent.click(upgradeButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: 'personalized_learner_schedules',
linkName: 'dates_upgrade',
linkType: 'button',
pageName: 'dates_tab',
});
});
});
describe('when receiving an access denied error', () => {
// These tests could go into any particular tab, as they all go through the same flow. But dates tab works.
async function renderDenied(errorCode) {
setMetadata({
course_access: {
has_access: false,
error_code: errorCode,
additional_context_user_message: 'uhoh oh no', // only used by audit_expired
},
});
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
}
beforeEach(() => {
axiosMock.onGet(datesUrl).reply(200, datesTabData);
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
});
it('redirects to course survey for a survey_required error code', async () => {
await renderDenied('survey_required');
expect(global.location.href).toEqual(`http://localhost/redirect/survey/${courseMetadata.id}`);
});
it('redirects to dashboard for an unfulfilled_milestones error code', async () => {
await renderDenied('unfulfilled_milestones');
expect(global.location.href).toEqual('http://localhost/redirect/dashboard');
});
it('redirects to the dashboard with an attached access_response_error for an audit_expired error code', async () => {
await renderDenied('audit_expired');
expect(global.location.href).toEqual('http://localhost/redirect/dashboard?access_response_error=uhoh%20oh%20no');
});
it('redirects to the dashboard with a notlive start date for a course_not_started error code', async () => {
await renderDenied('course_not_started');
expect(global.location.href).toEqual('http://localhost/redirect/dashboard?notlive=2/5/2013'); // date from factory
});
it('redirects to the home page when unauthenticated', async () => {
await renderDenied('authentication_required');
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
});
it('redirects to the home page when unenrolled', async () => {
await renderDenied('enrollment_required');
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
});
});
});

View File

@@ -0,0 +1,3 @@
import DatesTab from './DatesTab';
export default DatesTab;

View File

@@ -0,0 +1,41 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
completed: {
id: 'learning.dates.badge.completed',
defaultMessage: 'Completed',
description: 'shown as label for the assignments which learner has completed.',
},
dueNext: {
id: 'learning.dates.badge.dueNext',
defaultMessage: 'Due next',
description: 'Shown as label for the assignment which date is in the future',
},
pastDue: {
id: 'learning.dates.badge.pastDue',
defaultMessage: 'Past due',
description: 'Shown as label for the assignments which deadline has passed',
},
title: {
id: 'learning.dates.title',
defaultMessage: 'Important dates',
description: 'The title of dates tab (course timeline).',
},
today: {
id: 'learning.dates.badge.today',
defaultMessage: 'Today',
description: 'Label used when the scheduled date for the assignment matches the current day',
},
unreleased: {
id: 'learning.dates.badge.unreleased',
defaultMessage: 'Not yet released',
description: 'Shown as label for assignments which date is unknown yet',
},
verifiedOnly: {
id: 'learning.dates.badge.verifiedOnly',
defaultMessage: 'Verified only',
description: 'Shown as label for assignments which learner has no access to.',
},
});
export default messages;

View File

@@ -0,0 +1,162 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import {
FormattedDate,
FormattedTime,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { Tooltip, OverlayTrigger } from '@edx/paragon';
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useModel } from '../../../generic/model-store';
import { getBadgeListAndColor } from './badgelist';
import { isLearnerAssignment } from '../utils';
function Day({
date,
first,
intl,
items,
last,
/** [MM-P2P] Example */
mmp2p,
}) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
userTimezone,
} = useModel('courseHomeMeta', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const { color, badges } = getBadgeListAndColor(date, intl, null, items);
/** [MM-P2P] Experiment */
const mmp2pOverride = (
mmp2p.state.isEnabled
&& items.some((item) => item.dateType === 'verified-upgrade-deadline')
);
return (
<li className="dates-day pb-4" data-testid="dates-day">
{/* Top Line */}
{!first && <div className="dates-line-top border-1 border-left border-gray-900 bg-gray-900" />}
{/* Dot */}
<div className={classNames(color, 'dates-dot border border-gray-900')} />
{/* Bottom Line */}
{!last && <div className="dates-line-bottom border-1 border-left border-gray-900 bg-gray-900" />}
{/* Content */}
<div className="d-inline-block ml-3 pl-2">
<div className="row w-100 m-0 mb-1 align-items-center text-primary-700" data-testid="dates-header">
<FormattedDate
/** [MM-P2P] Experiment */
value={mmp2pOverride ? mmp2p.state.upgradeDeadline : date}
day="numeric"
month="short"
weekday="short"
year="numeric"
{...timezoneFormatArgs}
/>
{badges}
</div>
{items.map((item) => {
/** [MM-P2P] Experiment (conditional) */
const { badges: itemBadges } = mmp2pOverride
? getBadgeListAndColor(new Date(mmp2p.state.upgradeDeadline), intl, item, items)
: getBadgeListAndColor(date, intl, item, items);
const showDueDateTime = item.dateType === 'assignment-due-date';
const showLink = item.link && isLearnerAssignment(item);
const title = showLink ? (<u><a href={item.link} className="text-reset">{item.title}</a></u>) : item.title;
const available = item.learnerHasAccess && (item.link || !isLearnerAssignment(item));
const textColor = available ? 'text-primary-700' : 'text-gray-500';
return (
<div key={item.title + item.date} className={classNames(textColor, 'small pb-1')} data-testid="dates-item">
<div>
<span className="small">
<span className="font-weight-bold">{item.assignmentType && `${item.assignmentType}: `}{title}</span>
{showDueDateTime && (
<span>
<span className="mx-1">due</span>
<FormattedTime
value={date}
timeZoneName="short"
{...timezoneFormatArgs}
/>
</span>
)}
</span>
{itemBadges}
{item.extraInfo && (
<OverlayTrigger
placement="bottom"
overlay={
<Tooltip>{item.extraInfo}</Tooltip>
}
>
<FontAwesomeIcon icon={faInfoCircle} className="fa-xs ml-1 text-gray-700" data-testid="dates-extra-info" />
</OverlayTrigger>
)}
</div>
{ /** [MM-P2P] Experiment (conditional) */ }
{ mmp2pOverride
? (
<div className="small mb-2">
You are still eligible to upgrade to a Verified Certificate!
&nbsp; Unlock full course access and highlight the knowledge you&apos;ll gain.
</div>
)
: (item.description && <div className="small mb-2">{item.description}</div>)}
</div>
);
})}
</div>
</li>
);
}
Day.propTypes = {
date: PropTypes.objectOf(Date).isRequired,
first: PropTypes.bool,
intl: intlShape.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
date: PropTypes.string,
dateType: PropTypes.string,
description: PropTypes.string,
dueNext: PropTypes.bool,
learnerHasAccess: PropTypes.bool,
link: PropTypes.string,
title: PropTypes.string,
})).isRequired,
last: PropTypes.bool,
/** [MM-P2P] Experiment */
mmp2p: PropTypes.shape({
state: PropTypes.shape({
isEnabled: PropTypes.bool.isRequired,
upgradeDeadline: PropTypes.string,
}),
}),
};
Day.defaultProps = {
first: false,
last: false,
/** [MM-P2P] Experiment */
mmp2p: {
state: {
isEnabled: false,
upgradeDeadline: '',
},
},
};
export default injectIntl(Day);

View File

@@ -0,0 +1,47 @@
$dot-radius: 0.3rem;
$dot-size: $dot-radius * 2;
$offset: $dot-radius * 1.5;
.dates-day {
position: relative;
}
.dates-line-top {
display: inline-block;
position: absolute;
left: $offset;
top: 0;
height: $offset;
z-index: 0;
}
.dates-dot {
display: inline-block;
position: absolute;
border-radius: 50%;
left: $dot-radius * 0.5; // save room for today's larger size
top: $offset;
height: $dot-size;
width: $dot-size;
z-index: 1;
&.dates-bg-today {
left: 0;
top: $offset - $dot-radius;
height: $dot-size * 1.5;
width: $dot-size * 1.5;
}
}
.dates-line-bottom {
display: inline-block;
position: absolute;
top: $offset + $dot-size;
bottom: 0;
left: $offset;
z-index: 0;
}
.dates-bg-today {
background: #ffdb87;
}

View File

@@ -0,0 +1,82 @@
import React from 'react';
/** [MM-P2P] Experiment (import) */
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useModel } from '../../../generic/model-store';
import Day from './Day';
import { daycmp, isLearnerAssignment } from '../utils';
/** [MM-P2P] Experiment (argument) */
export default function Timeline({ mmp2p }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
courseDateBlocks,
} = useModel('dates', courseId);
// Group date items by day (assuming they are sorted in first place) and add some metadata
const groupedDates = [];
const now = new Date();
let foundNextDue = false;
let foundToday = false;
courseDateBlocks.forEach(courseDateBlock => {
const dateInfo = { ...courseDateBlock };
const parsedDate = new Date(dateInfo.date);
if (!foundNextDue && parsedDate >= now && isLearnerAssignment(dateInfo) && !dateInfo.complete) {
foundNextDue = true;
dateInfo.dueNext = true;
}
if (!foundToday) {
const compared = daycmp(parsedDate, now);
if (compared === 0) {
foundToday = true;
} else if (compared > 0) {
foundToday = true;
groupedDates.push({
date: now,
items: [],
});
}
}
if (groupedDates.length === 0 || daycmp(groupedDates[groupedDates.length - 1].date, parsedDate) !== 0) {
// Add new grouped date
groupedDates.push({
date: parsedDate,
items: [dateInfo],
first: groupedDates.length === 0,
});
} else {
groupedDates[groupedDates.length - 1].items.push(dateInfo);
}
});
if (!foundToday) {
groupedDates.push({ date: now, items: [] });
}
if (groupedDates.length) {
groupedDates[groupedDates.length - 1].last = true;
}
return (
<ul className="list-unstyled m-0 mt-4 pt-2">
{groupedDates.map((groupedDate) => (
<Day key={groupedDate.date} {...groupedDate} mmp2p={mmp2p} />
))}
</ul>
);
}
/** [MM-P2P] Experiment */
Timeline.propTypes = {
mmp2p: PropTypes.shape({}),
};
Timeline.defaultProps = {
mmp2p: {},
};

View File

@@ -0,0 +1,118 @@
import React from 'react';
import classNames from 'classnames';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons';
import { Badge } from '@edx/paragon';
import messages from '../messages';
import { daycmp, isLearnerAssignment } from '../utils';
function hasAccess(item) {
return item.learnerHasAccess;
}
function isComplete(assignment) {
return assignment.complete;
}
function isPastDue(assignment) {
return !isComplete(assignment) && (new Date(assignment.date) < new Date());
}
function isUnreleased(assignment) {
return !assignment.link;
}
// Pass a null item if you want to get a whole day's badge list, not just one item's list.
// Returns an object with 'color' and 'badges' properties.
function getBadgeListAndColor(date, intl, item, items) {
const now = new Date();
const assignments = items.filter(isLearnerAssignment);
const isToday = daycmp(date, now) === 0;
const isInFuture = daycmp(date, now) > 0;
// This badge info list is in order of priority (they will appear left to right in this order and the first badge
// sets the color of the dot in the timeline).
const badgesInfo = [
{
message: messages.today,
shownForDay: isToday,
bg: 'bg-warning-300',
className: 'text-black',
},
{
message: messages.completed,
shownForDay: assignments.length && assignments.every(isComplete),
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
bg: 'bg-light-500',
className: 'text-black',
},
{
message: messages.pastDue,
shownForDay: assignments.length && assignments.every(isPastDue),
shownForItem: x => isLearnerAssignment(x) && isPastDue(x),
bg: 'bg-dark-200',
className: 'text-white',
},
{
message: messages.dueNext,
shownForDay: !isToday && assignments.some(x => x.dueNext),
shownForItem: x => x.dueNext,
bg: 'bg-gray-500',
className: 'text-white',
},
{
message: messages.unreleased,
shownForDay: assignments.length && assignments.every(isUnreleased),
shownForItem: x => isLearnerAssignment(x) && isUnreleased(x),
className: 'border border-gray-500 text-gray-500',
},
{
message: messages.verifiedOnly,
shownForDay: items.length && items.every(x => !hasAccess(x)),
shownForItem: x => !hasAccess(x),
icon: faLock,
bg: 'bg-dark-700',
className: 'text-white',
},
];
let color = null; // first color of any badge
const badges = (
<>
{badgesInfo.map(b => {
let shown = b.shownForDay;
if (item) {
if (b.shownForDay) {
shown = false; // don't double up, if the day already has this badge
} else {
shown = b.shownForItem && b.shownForItem(item);
}
}
if (!shown) {
return null;
}
if (!color && !isInFuture) {
color = b.bg;
}
return (
<Badge key={b.message.id} className={classNames('ml-2', b.bg, b.className)} data-testid="dates-badge">
{b.icon && <FontAwesomeIcon icon={b.icon} className="mr-1" />}
{intl.formatMessage(b.message)}
</Badge>
);
})}
</>
);
if (!color && isInFuture) {
color = 'bg-gray-900';
}
return {
color,
badges,
};
}
// eslint-disable-next-line import/prefer-default-export
export { getBadgeListAndColor };

View File

@@ -0,0 +1,16 @@
function daycmp(a, b) {
if (a.getFullYear() < b.getFullYear()) { return -1; }
if (a.getFullYear() > b.getFullYear()) { return 1; }
if (a.getMonth() < b.getMonth()) { return -1; }
if (a.getMonth() > b.getMonth()) { return 1; }
if (a.getDate() < b.getDate()) { return -1; }
if (a.getDate() > b.getDate()) { return 1; }
return 0;
}
// item is a date block returned from the API
function isLearnerAssignment(item) {
return item.learnerHasAccess && item.dateType === 'assignment-due-date';
}
export { daycmp, isLearnerAssignment };

View File

@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import PageLoading from '../../generic/PageLoading';
import { unsubscribeFromCourseGoal } from '../data/api';
import messages from './messages';
import ResultPage from './ResultPage';
function GoalUnsubscribe({ intl }) {
const { token } = useParams();
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState({});
// We don't need to bother with redux for this simple page. We're not sharing state with other pages at all.
useEffect(() => {
unsubscribeFromCourseGoal(token)
.then(
(result) => {
setIsLoading(false);
setData(result.data);
},
() => {
setIsLoading(false);
setError(true);
},
);
// We unfortunately have no information about the user, course, org, or really anything
// as visiting this page is allowed to be done anonymously and without the context of the course.
// The token can be used to connect a user and course, it will just require some post-processing
sendTrackEvent('edx.ui.lms.goal.unsubscribe', { token });
}, []); // deps=[] to only run once
return (
<>
<Header showUserDropdown={false} />
<main id="main-content" className="container my-5 text-center">
{isLoading && (
<PageLoading srMessage={`${intl.formatMessage(messages.loading)}`} />
)}
{!isLoading && (
<ResultPage error={error} courseTitle={data.courseTitle} />
)}
</main>
</>
);
}
GoalUnsubscribe.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(GoalUnsubscribe);

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { Route } from 'react-router';
import MockAdapter from 'axios-mock-adapter';
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { render, screen } from '@testing-library/react';
import GoalUnsubscribe from './GoalUnsubscribe';
import { act, initializeMockApp } from '../../setupTest';
import initializeStore from '../../store';
import { UserMessagesProvider } from '../../generic/user-messages';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
describe('GoalUnsubscribe', () => {
let axiosMock;
let store;
let component;
const unsubscribeUrl = `${getConfig().LMS_BASE_URL}/api/course_home/unsubscribe_from_course_goal/TOKEN`;
beforeEach(() => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Route path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
</UserMessagesProvider>
</AppProvider>
);
history.push('/goal-unsubscribe/TOKEN'); // so we can pull token from url
});
it('starts with a spinner', () => {
render(component);
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('loads a real token', async () => {
const response = { course_title: 'My Sample Course' };
axiosMock.onPost(unsubscribeUrl).reply(200, response);
await act(async () => render(component));
expect(screen.getByText('Youve unsubscribed from goal reminders')).toBeInTheDocument();
expect(screen.getByText(/your goal for My Sample Course/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to dashboard' }))
.toHaveAttribute('href', 'http://localhost:18000/dashboard');
});
it('loads a bad token with an error page', async () => {
axiosMock.onPost(unsubscribeUrl).reply(404, {});
await act(async () => render(component));
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to dashboard' }))
.toHaveAttribute('href', 'http://localhost:18000/dashboard');
expect(screen.getByRole('link', { name: 'contact support' }))
.toHaveAttribute('href', 'http://localhost:18000/contact');
});
});

View File

@@ -0,0 +1,60 @@
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Hyperlink } from '@edx/paragon';
import messages from './messages';
import { ReactComponent as UnsubscribeIcon } from './unsubscribe.svg';
function ResultPage({ courseTitle, error, intl }) {
const errorDescription = (
<FormattedMessage
id="learning.goals.unsubscribe.errorDescription"
defaultMessage="We were unable to unsubscribe you from goal reminder emails. Please try again later or {contactSupport} for help."
values={{
contactSupport: (
<Hyperlink
className="text-reset"
style={{ textDecoration: 'underline' }}
destination={`${getConfig().CONTACT_URL}`}
>
{intl.formatMessage(messages.contactSupport)}
</Hyperlink>
),
}}
/>
);
const header = error
? intl.formatMessage(messages.errorHeader)
: intl.formatMessage(messages.header);
const description = error
? errorDescription
: intl.formatMessage(messages.description, { courseTitle });
return (
<>
<UnsubscribeIcon className="text-primary" alt="" />
<div role="heading" aria-level="1" className="h2">{header}</div>
<div className="row justify-content-center">
<div className="col-xl-7 col-12 p-0">{description}</div>
</div>
<Button variant="brand" href={`${getConfig().LMS_BASE_URL}/dashboard`} className="mt-4">
{intl.formatMessage(messages.goToDashboard)}
</Button>
</>
);
}
ResultPage.defaultProps = {
courseTitle: null,
error: false,
};
ResultPage.propTypes = {
courseTitle: PropTypes.string,
error: PropTypes.bool,
intl: intlShape.isRequired,
};
export default injectIntl(ResultPage);

View File

@@ -0,0 +1,3 @@
import GoalUnsubscribe from './GoalUnsubscribe';
export default GoalUnsubscribe;

View File

@@ -0,0 +1,36 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
contactSupport: {
id: 'learning.goals.unsubscribe.contact',
defaultMessage: 'contact support',
description: 'Its shown as a suggestion or recommendation for learner when their unsubscribing request has failed',
},
description: {
id: 'learning.goals.unsubscribe.description',
defaultMessage: 'You will no longer receive email reminders about your goal for {courseTitle}.',
description: 'It describes the consequences to learner when they unsubscribe of goal reminder service',
},
errorHeader: {
id: 'learning.goals.unsubscribe.errorHeader',
defaultMessage: 'Something went wrong',
description: 'It indicate that the unsubscribing request has failed',
},
goToDashboard: {
id: 'learning.goals.unsubscribe.goToDashboard',
defaultMessage: 'Go to dashboard',
description: 'Anchor text for button that redirects to dashboard page',
},
header: {
id: 'learning.goals.unsubscribe.header',
defaultMessage: 'Youve unsubscribed from goal reminders',
description: 'It indicate that the unsubscribing request was successful',
},
loading: {
id: 'learning.goals.unsubscribe.loading',
defaultMessage: 'Unsubscribing…',
description: 'Message shown when the unsubscribing request is processing',
},
});
export default messages;

View File

@@ -0,0 +1,5 @@
<svg width="167" height="153" viewBox="0 0 167 153" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M140.25 25.5H12.75V127.5H140.25V25.5ZM127.5 46L76.5 77.875L25.5 46V38.25L76.5 70.125L127.5 38.25V46Z" fill="currentColor"/>
<circle cx="134" cy="39" r="33" transform="rotate(-90 134 39)" fill="white"/>
<path d="M134 11C118.544 11 106 23.544 106 39C106 54.456 118.544 67 134 67C149.456 67 162 54.456 162 39C162 23.544 149.456 11 134 11ZM134 61.4C121.624 61.4 111.6 51.376 111.6 39C111.6 33.82 113.364 29.06 116.332 25.28L147.72 56.668C143.94 59.636 139.18 61.4 134 61.4ZM151.668 52.72L120.28 21.332C124.06 18.364 128.82 16.6 134 16.6C146.376 16.6 156.4 26.624 156.4 39C156.4 44.18 154.636 48.94 151.668 52.72Z" fill="#D23228"/>
</svg>

After

Width:  |  Height:  |  Size: 743 B

View File

@@ -1 +0,0 @@
export { default } from './CourseHomeContainer';

View File

@@ -1,11 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'learn.loading.outline': {
id: 'learn.loading.learning.sequence',
defaultMessage: 'Loading learning sequence...',
description: 'Message when learning sequence is being loaded',
},
});
export default messages;

View File

@@ -0,0 +1,130 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { FormattedDate } from '@edx/frontend-platform/i18n';
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { useModel } from '../../generic/model-store';
import { isLearnerAssignment } from '../dates-tab/utils';
import './DateSummary.scss';
export default function DateSummary({
dateBlock,
userTimezone,
/** [MM-P2P] Experiment */
mmp2p,
}) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
org,
} = useModel('courseHomeMeta', courseId);
const linkedTitle = dateBlock.link && isLearnerAssignment(dateBlock);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
/** [MM-P2P] Experiment */
const showMMP2P = mmp2p.state.isEnabled && (dateBlock.dateType === 'verified-upgrade-deadline');
const logVerifiedUpgradeClick = () => {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
org_key: org,
courserun_key: courseId,
linkCategory: '(none)',
linkName: 'course_home_dates',
linkType: 'link',
pageName: 'course_home',
});
};
return (
<li className="p-0 mb-3 small text-dark-500">
<div className="row">
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" fixedWidth />
<div className="ml-1 font-weight-bold">
<FormattedDate
/** [MM-P2P] Experiment */
value={showMMP2P ? mmp2p.state.upgradeDeadline : dateBlock.date}
day="numeric"
month="short"
weekday="short"
year="numeric"
{...timezoneFormatArgs}
/>
</div>
</div>
{/** [MM-P2P] Experiment (conditional) */}
{ showMMP2P ? (
<div className="row ml-4 pr-2">
<div className="date-summary-text">
<div className="font-weight-bold mt-2">
Last chance to upgrade
</div>
</div>
<div className="date-summary-text mt-1">
You are still eligible to upgrade to a Verified Certificate!
&nbsp; Unlock full course access and highlight the knowledge you&apos;ll gain.
</div>
</div>
) : (
<div className="row ml-4 pr-2">
<div className="date-summary-text">
{linkedTitle && (
<div className="font-weight-bold mt-2">
<a href={dateBlock.link}>{dateBlock.title}</a>
</div>
)}
{!linkedTitle && (
<div className="font-weight-bold mt-2">{dateBlock.title}</div>
)}
</div>
{dateBlock.description && (
<div className="date-summary-text mt-1">{dateBlock.description}</div>
)}
{!linkedTitle && dateBlock.link && (
<a
href={dateBlock.link}
onClick={dateBlock.dateType === 'verified-upgrade-deadline' ? logVerifiedUpgradeClick : () => {}}
className="description-link"
>
{dateBlock.linkText}
</a>
)}
</div>
)}
</li>
);
}
DateSummary.propTypes = {
dateBlock: PropTypes.shape({
date: PropTypes.string.isRequired,
dateType: PropTypes.string,
description: PropTypes.string,
link: PropTypes.string,
linkText: PropTypes.string,
title: PropTypes.string.isRequired,
learnerHasAccess: PropTypes.bool,
}).isRequired,
userTimezone: PropTypes.string,
/** [MM-P2P] Experiment */
mmp2p: PropTypes.shape({
state: PropTypes.shape({
isEnabled: PropTypes.bool.isRequired,
upgradeDeadline: PropTypes.string,
}),
}),
};
DateSummary.defaultProps = {
userTimezone: null,
/** [MM-P2P] Experiment */
mmp2p: {
state: {
isEnabled: false,
upgradeDeadline: '',
},
},
};

View File

@@ -0,0 +1,8 @@
.date-summary-text {
margin-left: 2px;
flex-basis: 100%;
}
.description-link {
margin-left: 1px;
}

View File

@@ -0,0 +1,11 @@
body a {
color: #00688D;
}
body.inline-link a {
text-decoration: underline;
}
body.small {
font-size: 0.875rem;
}

View File

@@ -0,0 +1,68 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
export default function LmsHtmlFragment({
className,
html,
title,
...rest
}) {
const wholePage = `
<html>
<head>
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
<link rel="stylesheet" href="/static/${getConfig().LEGACY_THEME_NAME ? `${getConfig().LEGACY_THEME_NAME}/` : ''}css/bootstrap/lms-main.css">
<link rel="stylesheet" type="text/css" href="${getConfig().BASE_URL}/src/course-home/outline-tab/LmsHtmlFragment.css">
</head>
<body class="${className}">${html}</body>
<script>
const resizer = new ResizeObserver(() => {
window.parent.postMessage({type: 'lmshtmlfragment.resize'}, '*');
});
resizer.observe(document.body);
</script>
</html>
`;
const iframe = useRef(null);
function resetIframeHeight() {
if (iframe?.current?.contentWindow?.document?.body) {
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
}
}
useEffect(() => {
function receiveMessage(event) {
const { type } = event.data;
if (type === 'lmshtmlfragment.resize') {
resetIframeHeight();
}
}
global.addEventListener('message', receiveMessage);
}, []);
return (
<iframe
className="w-100 border-0"
onLoad={resetIframeHeight}
ref={iframe}
referrerPolicy="origin"
scrolling="no"
srcDoc={wholePage}
title={title}
{...rest}
/>
);
}
LmsHtmlFragment.defaultProps = {
className: '',
};
LmsHtmlFragment.propTypes = {
className: PropTypes.string,
html: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,222 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates';
import CourseHandouts from './widgets/CourseHandouts';
import StartOrResumeCourseCard from './widgets/StartOrResumeCourseCard';
import WeeklyLearningGoalCard from './widgets/WeeklyLearningGoalCard';
import CourseTools from './widgets/CourseTools';
import { fetchOutlineTab } from '../data';
import messages from './messages';
import Section from './Section';
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
import useCertificateAvailableAlert from './alerts/certificate-status-alert';
import useCourseEndAlert from './alerts/course-end-alert';
import useCourseStartAlert from '../../alerts/course-start-alert';
import usePrivateCourseAlert from './alerts/private-course-alert';
import useScheduledContentAlert from './alerts/scheduled-content-alert';
import { useModel } from '../../generic/model-store';
import WelcomeMessage from './widgets/WelcomeMessage';
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
/** [MM-P2P] Experiment */
import { initHomeMMP2P, MMP2PFlyover } from '../../experiments/mm-p2p';
function OutlineTab({ intl }) {
const {
courseId,
proctoringPanelStatus,
} = useSelector(state => state.courseHome);
const {
isSelfPaced,
org,
title,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const {
accessExpiration,
courseBlocks: {
courses,
sections,
},
courseGoals: {
selectedGoal,
weeklyLearningGoalEnabled,
} = {},
datesBannerInfo,
datesWidget: {
courseDateBlocks,
},
enableProctoredExams,
offer,
timeOffsetMillis,
verifiedMode,
} = useModel('outline', courseId);
const {
marketingUrl,
} = useModel('coursewareMeta', courseId);
const [expandAll, setExpandAll] = useState(false);
const eventProperties = {
org_key: org,
courserun_key: courseId,
};
// Below the course title alerts (appearing in the order listed here)
const courseStartAlert = useCourseStartAlert(courseId);
const courseEndAlert = useCourseEndAlert(courseId);
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
const privateCourseAlert = usePrivateCourseAlert(courseId);
const scheduledContentAlert = useScheduledContentAlert(courseId);
const rootCourseId = courses && Object.keys(courses)[0];
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
const logUpgradeToShiftDatesLinkClick = () => {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
...eventProperties,
linkCategory: 'personalized_learner_schedules',
linkName: 'course_home_upgrade_shift_dates',
linkType: 'button',
pageName: 'course_home',
});
};
const isEnterpriseUser = () => {
const authenticatedUser = getAuthenticatedUser();
const userRoleNames = authenticatedUser ? authenticatedUser.roles.map(role => role.split(':')[0]) : [];
return userRoleNames.includes('enterprise_learner');
};
/** [[MM-P2P] Experiment */
const MMP2P = initHomeMMP2P(courseId);
/** show post enrolment survey to only B2C learners */
const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner';
return (
<>
<div data-learner-type={learnerType} className="row w-100 mx-0 my-3 justify-content-between">
<div className="col-12 col-sm-auto p-0">
<div role="heading" aria-level="1" className="h2">{title}</div>
</div>
</div>
{/** [MM-P2P] Experiment (className for optimizely trigger) */}
<div className="row course-outline-tab">
<AccountActivationAlert />
<div className="col-12">
<AlertList
topic="outline-private-alerts"
customAlerts={{
...privateCourseAlert,
}}
/>
</div>
<div className="col col-12 col-md-8">
{ /** [MM-P2P] Experiment (the conditional) */ }
{ !MMP2P.state.isEnabled
&& (
<AlertList
topic="outline-course-alerts"
className="mb-3"
customAlerts={{
...certificateAvailableAlert,
...courseEndAlert,
...courseStartAlert,
...scheduledContentAlert,
}}
/>
)}
{isSelfPaced && hasDeadlines && !MMP2P.state.isEnabled && (
<>
<ShiftDatesAlert model="outline" fetch={fetchOutlineTab} />
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} />
</>
)}
<StartOrResumeCourseCard />
<WelcomeMessage courseId={courseId} />
{rootCourseId && (
<>
<div className="row w-100 m-0 mb-3 justify-content-end">
<div className="col-12 col-md-auto p-0">
<Button variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
{expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)}
</Button>
</div>
</div>
<ol id="courseHome-outline" className="list-unstyled">
{courses[rootCourseId].sectionIds.map((sectionId) => (
<Section
key={sectionId}
courseId={courseId}
defaultOpen={sections[sectionId].resumeBlock}
expand={expandAll}
section={sections[sectionId]}
/>
))}
</ol>
</>
)}
</div>
{rootCourseId && (
<div className="col col-12 col-md-4">
<ProctoringInfoPanel />
{ /** Defer showing the goal widget until the ProctoringInfoPanel has resolved or has been determined as
disabled to avoid components bouncing around too much as screen is rendered */ }
{(!enableProctoredExams || proctoringPanelStatus === 'loaded') && weeklyLearningGoalEnabled && (
<WeeklyLearningGoalCard
daysPerWeek={selectedGoal && 'daysPerWeek' in selectedGoal ? selectedGoal.daysPerWeek : null}
subscribedToReminders={selectedGoal && 'subscribedToReminders' in selectedGoal ? selectedGoal.subscribedToReminders : false}
/>
)}
<CourseTools />
{ /** [MM-P2P] Experiment (conditional) */ }
{ MMP2P.state.isEnabled
? <MMP2PFlyover isStatic options={MMP2P} />
: (
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="course_home"
userTimezone={userTimezone}
shouldDisplayBorder
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
/>
)}
<CourseDates
/** [MM-P2P] Experiment */
mmp2p={MMP2P}
/>
<CourseHandouts />
</div>
)}
</div>
</>
);
}
OutlineTab.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(OutlineTab);

View File

@@ -0,0 +1,1279 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import Cookies from 'js-cookie';
import userEvent from '@testing-library/user-event';
import messages from './messages';
import { buildMinimalCourseBlocks } from '../../shared/data/__factories__/courseBlocks.factory';
import {
fireEvent, initializeMockApp, logUnhandledRequests, render, screen, waitFor, act,
} from '../../setupTest';
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
import * as thunks from '../data/thunks';
import initializeStore from '../../store';
import { CERT_STATUS_TYPE } from './alerts/certificate-status-alert/CertificateStatusAlert';
import OutlineTab from './OutlineTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
describe('Outline Tab', () => {
let axiosMock;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}&username=MockUser`;
const store = initializeStore();
const defaultMetadata = Factory.build('courseHomeMetadata');
const defaultTabData = Factory.build('outlineTabData');
function setMetadata(attributes, options) {
const courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
}
function setTabData(attributes, options) {
const outlineTabData = Factory.build('outlineTabData', attributes, options);
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
}
async function fetchAndRender() {
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render(<OutlineTab />, { store }));
}
beforeEach(async () => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
// Set defaults for network requests
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onPost(enrollmentUrl).reply(200, {});
axiosMock.onPost(goalUrl).reply(200, { header: 'Success' });
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
axiosMock.onGet(outlineUrl).reply(200, defaultTabData);
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'created',
onboarding_link: 'test',
expiration_date: null,
});
logUnhandledRequests(axiosMock);
});
describe('Course Outline', () => {
it('displays link to start course', async () => {
await fetchAndRender();
expect(screen.getByRole('link', { name: messages.start.defaultMessage })).toBeInTheDocument();
});
it('displays link to resume course', async () => {
setTabData({
resume_course: {
has_visited_course: true,
url: `${getConfig().LMS_BASE_URL}/courses/${courseId}/jump_to/block-v1:edX+Test+Block@12345abcde`,
},
});
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Resume course' })).toBeInTheDocument();
});
it('expands section that contains resume block', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
const expandedSectionNode = screen.getByRole('button', { name: /Title of Section/ });
expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true');
});
it('handles expand/collapse all button click', async () => {
await fetchAndRender();
// Button renders as "Expand All"
const expandButton = screen.getByRole('button', { name: 'Expand all' });
expect(expandButton).toBeInTheDocument();
// Section initially renders collapsed
const collapsedSectionNode = screen.getByRole('button', { name: /section/ });
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
// Click to expand section
userEvent.click(expandButton);
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
// Click to collapse section
userEvent.click(expandButton);
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
});
it('displays correct icon for complete assignment', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { complete: true });
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
expect(screen.getByTitle('Completed section')).toBeInTheDocument();
});
it('displays correct icon for incomplete assignment', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { complete: false });
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
});
it('SequenceLink displays points to legacy courseware', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
setMetadata({
can_load_courseware: false,
});
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
const sequenceLink = screen.getByText('Title of Sequence');
expect(sequenceLink.getAttribute('href')).toContain(`/courses/${courseId}`);
});
it('SequenceLink displays points to courseware MFE', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
setMetadata({
can_load_courseware: true,
});
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
const sequenceLink = screen.getByText('Title of Sequence');
expect(sequenceLink.getAttribute('href')).toContain(`/course/${courseId}`);
});
});
describe('Suggested schedule alerts', () => {
beforeEach(() => {
setMetadata({ is_enrolled: true, is_self_paced: true });
setTabData({
dates_banner_info: {
content_type_gating_enabled: true,
missed_deadlines: true,
missed_gated_content: true,
verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5',
},
}, {
date_blocks: [
{
assignment_type: 'Homework',
date: '2010-08-20T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
title: 'Missed assignment',
extra_info: null,
},
],
});
});
it('renders UpgradeToShiftDatesAlert', async () => {
await fetchAndRender();
expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument();
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Dont worry—you wont lose any of the progress youve made when you shift your due dates.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument();
});
it('sends analytics event onClick of upgrade button in UpgradeToShiftDatesAlert', async () => {
await fetchAndRender();
sendTrackEvent.mockClear();
const upgradeButton = screen.getByRole('button', { name: 'Upgrade to shift due dates' });
fireEvent.click(upgradeButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: 'personalized_learner_schedules',
linkName: 'course_home_upgrade_shift_dates',
linkType: 'button',
pageName: 'course_home',
});
});
});
describe('Welcome Message', () => {
beforeEach(() => {
setMetadata({ is_enrolled: true });
});
it('does not render show more/less button under 100 words', async () => {
await fetchAndRender();
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument();
});
describe('over 100 words', () => {
beforeEach(async () => {
setTabData({
welcome_message_html: '<p>'
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
+ '</p>',
});
await fetchAndRender();
});
it('shortens message', async () => {
expect(screen.getByTestId('short-welcome-message-iframe')).toBeInTheDocument();
const showMoreButton = screen.queryByRole('button', { name: 'Show More' });
expect(showMoreButton).toBeInTheDocument();
});
it('renders show more/less button and handles click', async () => {
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
let showMoreButton = screen.getByRole('button', { name: 'Show More' });
expect(showMoreButton).toBeInTheDocument();
userEvent.click(showMoreButton);
let showLessButton = screen.getByRole('button', { name: 'Show Less' });
expect(showLessButton).toBeInTheDocument();
expect(screen.getByTestId('long-welcome-message-iframe')).toBeInTheDocument();
userEvent.click(showLessButton);
showLessButton = screen.queryByRole('button', { name: 'Show Less' });
expect(showLessButton).not.toBeInTheDocument();
showMoreButton = screen.getByRole('button', { name: 'Show More' });
expect(showMoreButton).toBeInTheDocument();
});
});
it('does not display if no update available', async () => {
setTabData({ welcome_message_html: null });
await fetchAndRender();
expect(screen.queryByTestId('alert-container-welcome')).not.toBeInTheDocument();
});
});
describe('Course Dates', () => {
it('renders when course date blocks are populated', async () => {
const startDate = new Date();
startDate.setHours(startDate.getHours() + 1);
setMetadata({ is_enrolled: true });
setTabData({}, {
date_blocks: [
{
date_type: 'course-start-date',
date: startDate.toISOString(),
title: 'Start',
},
],
});
await fetchAndRender();
expect(screen.getByRole('heading', { name: 'Important dates' })).toBeInTheDocument();
});
it('does not render when course date blocks are not populated', async () => {
setMetadata({ is_enrolled: true });
await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Important dates' })).not.toBeInTheDocument();
});
it('sends analytics event onClick of upgrade link', async () => {
const now = new Date();
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({}, {
date_blocks: [
{
date_type: 'verified-upgrade-deadline',
date: tomorrow.toISOString(),
link: 'https://example.com/upgrade',
link_text: 'Upgrade to Verified Certificate',
title: 'Verification Upgrade Deadline',
},
],
});
await fetchAndRender();
sendTrackEvent.mockClear();
const upgradeLink = screen.getByRole('link', { name: 'Upgrade to Verified Certificate' });
fireEvent.click(upgradeLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: '(none)',
linkName: 'course_home_dates',
linkType: 'link',
pageName: 'course_home',
});
});
});
describe('Start or Resume Course Card', () => {
it('renders startOrResumeCourseCard', async () => {
await fetchAndRender();
expect(screen.queryByTestId('start-resume-card')).toBeInTheDocument();
});
});
describe('Weekly Learning Goal', () => {
it('does not post goals while masquerading', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
},
});
const spy = jest.spyOn(thunks, 'saveWeeklyLearningGoal');
await fetchAndRender();
const button = await screen.getByTestId('weekly-learning-goal-input-Regular');
fireEvent.click(button);
expect(spy).toHaveBeenCalledTimes(0);
});
describe('weekly learning goal is not set', () => {
beforeEach(async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
},
});
await fetchAndRender();
});
it('renders weekly learning goal card', async () => {
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
});
it('disables the subscribe button if no goal is set', async () => {
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeDisabled();
});
it.each`
level | days
${'Casual'} | ${1}
${'Regular'} | ${3}
${'Intense'} | ${5}
`('calls the API with a goal of $days when $level goal is clicked', async ({ level, days }) => {
// click on Casual goal
const button = await screen.queryByTestId(`weekly-learning-goal-input-${level}`);
fireEvent.click(button);
// Verify the request was made
await waitFor(() => {
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
// subscribe is turned on automatically
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","days_per_week":${days},"subscribed_to_reminders":true}`);
// verify that the additional info about subscriptions shows up
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).toBeInTheDocument();
});
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled();
});
it('shows and hides subscribe to reminders additional text', async () => {
const button = await screen.getByTestId('weekly-learning-goal-input-Regular');
fireEvent.click(button);
// Verify the request was made
await waitFor(() => {
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
// subscribe is turned on automatically
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","days_per_week":3,"subscribed_to_reminders":true}`);
// verify that the additional info about subscriptions shows up
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).toBeInTheDocument();
});
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled();
// Click on subscribe to reminders toggle
const subscriptionSwitch = await screen.getByRole('switch', { name: messages.setGoalReminder.defaultMessage });
expect(subscriptionSwitch).toBeInTheDocument();
fireEvent.click(subscriptionSwitch);
await waitFor(() => {
expect(axiosMock.history.post[1].url).toMatch(goalUrl);
expect(axiosMock.history.post[1].data)
.toMatch(`{"course_id":"${courseId}","days_per_week":3,"subscribed_to_reminders":false}`);
});
// verify that the additional info about subscriptions gets hidden
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).not.toBeInTheDocument();
});
});
it('has button for weekly learning goal selected', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
selected_goal: {
subscribed_to_reminders: true,
days_per_week: 3,
},
},
});
await fetchAndRender();
const button = await screen.queryByTestId('weekly-learning-goal-input-Regular');
expect(button).toBeInTheDocument();
expect(button).toHaveClass('flag-button-selected');
});
it('renders weekly learning goal card if ProctoringInfoPanel is not shown', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
},
});
axiosMock.onGet(proctoringInfoUrl).reply(404);
await fetchAndRender();
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
});
it('renders weekly learning goal card if ProctoringInfoPanel is not enabled', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
enableProctoredExams: false,
},
});
await fetchAndRender();
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
});
it('renders weekly learning goal card if ProctoringInfoPanel is enabled', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
enableProctoredExams: true,
},
});
await fetchAndRender();
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
});
});
describe('Course Handouts', () => {
it('renders title when handouts are available', async () => {
await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Course Handouts' })).toBeInTheDocument();
});
it('does not display title if no handouts available', async () => {
setTabData({ handouts_html: null });
await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Course Handouts' })).not.toBeInTheDocument();
});
});
describe('Course Tools', () => {
it('renders title when tools are available', async () => {
await fetchAndRender();
expect(screen.getByRole('heading', { name: 'Course Tools' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Bookmarks' })).toBeInTheDocument();
});
it('does not render title when tools are not available', async () => {
setTabData({
course_tools: [],
});
await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Course Tools' })).not.toBeInTheDocument();
});
});
describe('Alert List', () => {
describe('Private Course Alert', () => {
it('does not display alert for enrolled user', async () => {
setMetadata({ is_enrolled: true });
await fetchAndRender();
expect(screen.queryByRole('button', { name: 'Enroll now' })).not.toBeInTheDocument();
expect(screen.queryByText('to access the full course')).not.toBeInTheDocument();
});
it('does not display enrollment button if enrollment is not available', async () => {
setTabData({
enroll_alert: {
can_enroll: false,
},
});
await fetchAndRender();
const alert = await screen.findByTestId('private-course-alert');
expect(alert).toHaveAttribute('role', 'alert');
expect(screen.queryByRole('button', { name: 'Enroll now' })).not.toBeInTheDocument();
expect(screen.getByText('You must be enrolled in the course to see course content.')).toBeInTheDocument();
});
it('displays alert for unenrolled user', async () => {
await fetchAndRender();
const alert = await screen.findByTestId('private-course-alert');
expect(alert).toHaveAttribute('role', 'alert');
expect(screen.getByRole('button', { name: 'Enroll now' })).toBeInTheDocument();
});
it('handles button click', async () => {
const { location } = window;
delete window.location;
window.location = {
reload: jest.fn(),
};
await fetchAndRender();
const button = await screen.findByRole('button', { name: 'Enroll now' });
fireEvent.click(button);
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
expect(axiosMock.history.post[0].data)
.toEqual(JSON.stringify({ course_details: { course_id: courseId } }));
expect(window.location.reload).toHaveBeenCalledTimes(1);
window.location = location;
});
});
describe('Access Expiration Alert', () => {
it('renders page banner on masquerade', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: true,
},
});
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="outline">...</LoadedTabPage>, { store }));
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
expect(instructorToolbar).toBeInTheDocument();
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
});
it('does not render banner when not masquerading', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: false,
},
});
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="outline">...</LoadedTabPage>, { store }));
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
expect(instructorToolbar).toBeInTheDocument();
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
});
});
describe('Course Start Alert', () => {
// Only appears if enrolled and before start of course
it('appears several days out', async () => {
const startDate = new Date();
startDate.setDate(startDate.getDate() + 100);
setMetadata({ is_enrolled: true, start: '2999-01-01T00:00:00Z' });
await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
});
it('appears today', async () => {
const startDate = new Date();
startDate.setHours(startDate.getHours() + 1);
setMetadata({ is_enrolled: true, start: startDate });
await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date
});
});
describe('Course End Alert', () => {
// Only appears if enrolled and within 14 days before the end of course
it('appears several days out', async () => {
const endDate = new Date();
endDate.setDate(endDate.getDate() + 13);
setMetadata({ is_enrolled: true });
setTabData({}, {
date_blocks: [
{
date_type: 'course-end-date',
date: endDate.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
const node = await screen.findByText('This course is ending', { exact: false });
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
});
it('appears today', async () => {
const endDate = new Date();
endDate.setHours(endDate.getHours() + 1);
setMetadata({ is_enrolled: true });
setTabData({}, {
date_blocks: [
{
date_type: 'course-end-date',
date: endDate.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
const node = await screen.findByText('This course is ending', { exact: false });
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date
});
});
describe('Certificate Available Alert', () => {
// Must satisfy two conditions for alert to appear: enrolled and between course end and cert availability
it('appears', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE,
cert_web_view_url: null,
certificate_available_date: tomorrow.toISOString(),
download_url: null,
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
],
});
await fetchAndRender();
expect(screen.queryByText('Your grade and certificate status will be available soon.')).toBeInTheDocument();
});
it('renders verification alert', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
cert_web_view_url: null,
download_url: null,
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
{
date_type: 'verification-deadline-date',
date: tomorrow.toISOString(),
link_text: 'Verify',
title: 'Verification Upgrade Deadline',
},
],
});
await fetchAndRender();
expect(screen.queryByText('Verify your identity to qualify for a certificate.')).toBeInTheDocument();
});
it('renders non passing grade', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {},
user_has_passing_grade: false,
has_ended: true,
enrollment_mode: 'verified',
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
{
date_type: 'verification-deadline-date',
date: tomorrow.toISOString(),
link_text: 'Verify',
title: 'Verification Upgrade Deadline',
},
],
});
await fetchAndRender();
screen.getAllByText('You are not yet eligible for a certificate');
expect(screen.queryByText('You are not yet eligible for a certificate')).toBeInTheDocument();
});
it('tracks request cert button', async () => {
sendTrackEvent.mockClear();
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.REQUESTING,
cert_web_view_url: null,
download_url: null,
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
{
date_type: 'verification-deadline-date',
date: tomorrow.toISOString(),
link_text: 'Verify',
title: 'Verification Upgrade Deadline',
},
],
});
await fetchAndRender();
sendTrackEvent.mockClear();
const requestingButton = screen.getByRole('button', { name: 'Request certificate' });
fireEvent.click(requestingButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
{
courserun_key: courseId,
is_staff: false,
org_key: 'edX',
});
});
it('tracks download cert button', async () => {
sendTrackEvent.mockClear();
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
cert_web_view_url: null,
download_url: null,
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
{
date_type: 'verification-deadline-date',
date: tomorrow.toISOString(),
link_text: 'Verify',
title: 'Verification Upgrade Deadline',
},
],
});
await fetchAndRender();
sendTrackEvent.mockClear();
const requestingButton = screen.getByRole('button', { name: 'View my certificate' });
fireEvent.click(requestingButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_downloadable_button.clicked',
{
courserun_key: courseId,
is_staff: false,
org_key: 'edX',
});
});
it('tracks unverified cert button', async () => {
sendTrackEvent.mockClear();
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
cert_web_view_url: null,
download_url: null,
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
{
date_type: 'verification-deadline-date',
date: tomorrow.toISOString(),
link_text: 'Verify',
title: 'Verification Upgrade Deadline',
},
],
});
await fetchAndRender();
sendTrackEvent.mockClear();
const requestingButton = screen.getByRole('link', { name: 'Verify my ID' });
fireEvent.click(requestingButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
{
courserun_key: courseId,
is_staff: false,
org_key: 'edX',
});
});
});
describe('Scheduled Content Alert', () => {
it('appears correctly', async () => {
const now = new Date();
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { hasScheduledContent: true });
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
date_blocks: [
{
date_type: 'course-end-date',
date: tomorrow.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
expect(screen.queryByText('More content is coming soon!')).toBeInTheDocument();
});
});
describe('Scheduled Content Alert not present without courseBlocks', () => {
it('appears correctly', async () => {
const now = new Date();
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
course_blocks: null,
date_blocks: [
{
date_type: 'course-end-date',
date: tomorrow.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
expect(screen.getByRole('link', { name: messages.start.defaultMessage })).toBeInTheDocument();
expect(screen.queryByText('More content is coming soon!')).not.toBeInTheDocument();
});
});
});
describe('Certificate (web) Complete Alert', () => {
it('appears', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
cert_web_view_url: 'certificate/testuuid',
certificate_available_date: null,
download_url: null,
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument();
});
});
describe('Requesting Certificate Alert', () => {
it('appears', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.REQUESTING,
cert_web_view_url: null,
certificate_available_date: null,
download_url: null,
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument();
expect(screen.queryByText('Request certificate')).toBeInTheDocument();
});
});
describe('Certificate (pdf) Complete Alert', () => {
it('appears', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
cert_web_view_url: null,
certificate_available_date: null,
download_url: 'download/url',
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
});
});
describe('Proctoring Info Panel', () => {
const onboardingReleaseDate = new Date();
onboardingReleaseDate.setDate(new Date().getDate() - 7);
it('appears', async () => {
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
});
it('appears for verified', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'verified',
onboarding_link: 'test',
expiration_date: null,
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByRole('link', { name: 'Complete Onboarding' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
expect(screen.queryByText('You must complete the onboarding process prior to taking any proctored exam.')).not.toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).not.toBeInTheDocument();
});
it('appears for rejected', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'rejected',
onboarding_link: 'test',
expiration_date: null,
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByRole('link', { name: 'Complete Onboarding' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
expect(screen.queryByText('You must complete the onboarding process prior to taking any proctored exam.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
});
it('appears for submitted', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'submitted',
onboarding_link: 'test',
expiration_date: null,
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your submitted profile is in review.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
});
it('appears for second_review_required', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'second_review_required',
onboarding_link: 'test',
expiration_date: null,
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your submitted profile is in review.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
});
it('appears for other_course_approved if not expiring soon', async () => {
const expirationDate = new Date();
// Set the expiration date 40 days in the future, so as not to trigger the 28 day expiration warning
expirationDate.setTime(expirationDate.getTime() + 3456900000);
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'other_course_approved',
onboarding_link: 'test',
expiration_date: expirationDate.toString(),
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding exam has been approved in another course.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).not.toBeInTheDocument();
});
it('displays expiration warning', async () => {
const expirationDate = new Date();
// This message will render if the expiration date is within 28 days; set the date 10 days in future
expirationDate.setTime(expirationDate.getTime() + 864800000);
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'other_course_approved',
onboarding_link: 'test',
expiration_date: expirationDate.toString(),
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding profile has been approved 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();
});
it('appears for no status', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: '',
onboarding_link: 'test',
expiration_date: null,
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByRole('link', { name: 'Complete Onboarding' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
expect(screen.queryByText('You must complete the onboarding process prior to taking any proctored exam.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
});
it('does not appear for 404', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(404);
await fetchAndRender();
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).not.toBeInTheDocument();
});
it('appears with a disabled link if onboarding not yet released', async () => {
const futureReleaseDate = new Date();
futureReleaseDate.setDate(new Date().getDate() + 7);
const expectedDateStr = new Intl.DateTimeFormat('en-US', {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(futureReleaseDate);
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: '',
onboarding_link: 'test',
expiration_date: null,
onboarding_release_date: futureReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText(`Onboarding Opens: ${expectedDateStr}`)).toBeInTheDocument();
});
it('appears and ignores a missing release date', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'verified',
onboarding_link: 'test',
expiration_date: null,
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByRole('link', { name: 'Complete Onboarding' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
expect(screen.queryByText('You must complete the onboarding process prior to taking any proctored exam.')).not.toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).not.toBeInTheDocument();
});
});
describe('Upgrade Card', () => {
it('renders title when upgrade is available', async () => {
await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
});
it('displays link to upgrade', async () => {
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
});
it('viewing upgrade card sends analytics', async () => {
sendTrackEvent.mockClear();
sendTrackingLogEvent.mockClear();
await fetchAndRender();
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('Promotion Viewed', {
org_key: 'edX',
courserun_key: courseId,
creative: 'sidebarupsell',
name: 'In-Course Verification Prompt',
position: 'sidebar-message',
promotion_id: 'courseware_verified_certificate_upsell',
});
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(1);
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.bi.course.upgrade.sidebarupsell.displayed', {
org_key: 'edX',
courserun_key: courseId,
});
});
it('clicking upgrade link sends analytics', async () => {
await fetchAndRender();
// Clearing after render to remove any events sent on view (ex. 'Promotion Viewed')
sendTrackEvent.mockClear();
sendTrackingLogEvent.mockClear();
const upgradeButton = screen.getByRole('link', { name: 'Upgrade for $149' });
fireEvent.click(upgradeButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'Promotion Clicked', {
org_key: 'edX',
courserun_key: courseId,
creative: 'sidebarupsell',
name: 'In-Course Verification Prompt',
position: 'sidebar-message',
promotion_id: 'courseware_verified_certificate_upsell',
});
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: 'green_upgrade',
linkName: 'course_home_green',
linkType: 'button',
pageName: 'course_home',
});
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(2);
expect(sendTrackingLogEvent).toHaveBeenNthCalledWith(1, 'edx.bi.course.upgrade.sidebarupsell.clicked', {
org_key: 'edX',
courserun_key: courseId,
});
expect(sendTrackingLogEvent).toHaveBeenNthCalledWith(2, 'edx.course.enrollment.upgrade.clicked', {
org_key: 'edX',
courserun_key: courseId,
location: 'sidebar-message',
});
});
});
describe('Account Activation Alert', () => {
beforeEach(() => {
const intersectionObserverMock = () => ({
observe: () => null,
disconnect: () => null,
});
window.IntersectionObserver = jest.fn().mockImplementation(intersectionObserverMock);
});
it('displays account activation alert if cookie is set true', async () => {
Cookies.set = jest.fn();
Cookies.get = jest.fn().mockImplementation(() => 'true');
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });
await fetchAndRender();
expect(screen.queryByText('Activate your account so you can log back in')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'resend the email' })).toBeInTheDocument();
});
it('do not displays account activation alert if cookie is not set true', async () => {
Cookies.set = jest.fn();
Cookies.get = jest.fn();
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });
await fetchAndRender();
expect(screen.queryByText('Activate your account so you can log back in')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'resend the email' })).not.toBeInTheDocument();
});
it('sends account activation email on clicking the re-send email in account activation alert', async () => {
Cookies.set = jest.fn();
Cookies.get = jest.fn().mockImplementation(() => 'true');
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });
await fetchAndRender();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const resendEmailUrl = `${getConfig().LMS_BASE_URL}/api/send_account_activation_email`;
axiosMock.onPost(resendEmailUrl).reply(200, {});
const resendLink = screen.getByRole('button', { name: 'resend the email' });
fireEvent.click(resendLink);
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
expect(axiosMock.history.post[0].url).toEqual(resendEmailUrl);
});
});
});

View File

@@ -0,0 +1,122 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Collapsible, IconButton } from '@edx/paragon';
import { faCheckCircle as fasCheckCircle, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import SequenceLink from './SequenceLink';
import { useModel } from '../../generic/model-store';
import genericMessages from '../../generic/messages';
import messages from './messages';
function Section({
courseId,
defaultOpen,
expand,
intl,
section,
}) {
const {
complete,
sequenceIds,
title,
} = section;
const {
courseBlocks: {
sequences,
},
} = useModel('outline', courseId);
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
setOpen(expand);
}, [expand]);
useEffect(() => {
setOpen(defaultOpen);
}, []);
const sectionTitle = (
<div className="row w-100 m-0">
<div className="col-auto p-0">
{complete ? (
<FontAwesomeIcon
icon={fasCheckCircle}
fixedWidth
className="float-left mt-1 text-success"
aria-hidden="true"
title={intl.formatMessage(messages.completedSection)}
/>
) : (
<FontAwesomeIcon
icon={farCheckCircle}
fixedWidth
className="float-left mt-1 text-gray-400"
aria-hidden="true"
title={intl.formatMessage(messages.incompleteSection)}
/>
)}
</div>
<div className="col-10 ml-3 p-0 font-weight-bold text-dark-500">
<span className="align-middle">{title}</span>
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
</span>
</div>
</div>
);
return (
<li>
<Collapsible
className="mb-2"
styling="card-lg"
title={sectionTitle}
open={open}
onToggle={() => { setOpen(!open); }}
iconWhenClosed={(
<IconButton
alt={intl.formatMessage(messages.openSection)}
icon={faPlus}
onClick={() => { setOpen(true); }}
size="sm"
/>
)}
iconWhenOpen={(
<IconButton
alt={intl.formatMessage(genericMessages.close)}
icon={faMinus}
onClick={() => { setOpen(false); }}
size="sm"
/>
)}
>
<ol className="list-unstyled">
{sequenceIds.map((sequenceId, index) => (
<SequenceLink
key={sequenceId}
id={sequenceId}
courseId={courseId}
sequence={sequences[sequenceId]}
first={index === 0}
/>
))}
</ol>
</Collapsible>
</li>
);
}
Section.propTypes = {
courseId: PropTypes.string.isRequired,
defaultOpen: PropTypes.bool.isRequired,
expand: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
section: PropTypes.shape().isRequired,
};
export default injectIntl(Section);

View File

@@ -0,0 +1,121 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { Hyperlink } from '@edx/paragon';
import {
FormattedMessage,
FormattedTime,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { faCheckCircle as fasCheckCircle } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import EffortEstimate from '../../shared/effort-estimate';
import { useModel } from '../../generic/model-store';
import messages from './messages';
function SequenceLink({
id,
intl,
courseId,
first,
sequence,
}) {
const {
complete,
description,
due,
legacyWebUrl,
showLink,
title,
} = sequence;
const {
userTimezone,
} = useModel('outline', courseId);
const {
canLoadCourseware,
} = useModel('courseHomeMeta', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
// canLoadCourseware is true if the Courseware MFE is enabled, false otherwise
const coursewareUrl = (
canLoadCourseware
? <Link to={`/course/${courseId}/${id}`}>{title}</Link>
: <Hyperlink destination={legacyWebUrl}>{title}</Hyperlink>
);
const displayTitle = showLink ? coursewareUrl : title;
return (
<li>
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
<div className="row w-100 m-0">
<div className="col-auto p-0">
{complete ? (
<FontAwesomeIcon
icon={fasCheckCircle}
fixedWidth
className="float-left text-success mt-1"
aria-hidden="true"
title={intl.formatMessage(messages.completedAssignment)}
/>
) : (
<FontAwesomeIcon
icon={farCheckCircle}
fixedWidth
className="float-left text-gray-400 mt-1"
aria-hidden="true"
title={intl.formatMessage(messages.incompleteAssignment)}
/>
)}
</div>
<div className="col-10 p-0 ml-3 text-break">
<span className="align-middle">{displayTitle}</span>
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)}
</span>
<EffortEstimate className="ml-3 align-middle" block={sequence} />
</div>
</div>
{due && (
<div className="row w-100 m-0 ml-3 pl-3">
<small className="text-body pl-2">
<FormattedMessage
id="learning.outline.sequence-due"
defaultMessage="{description} due {assignmentDue}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
</small>
</div>
)}
</div>
</li>
);
}
SequenceLink.propTypes = {
id: PropTypes.string.isRequired,
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
first: PropTypes.bool.isRequired,
sequence: PropTypes.shape().isRequired,
};
export default injectIntl(SequenceLink);

View File

@@ -0,0 +1,219 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedDate,
FormattedMessage,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { Alert, Button } from '@edx/paragon';
import { useDispatch } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCheckCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import certMessages from './messages';
import certStatusMessages from '../../../progress-tab/certificate-status/messages';
import { requestCert } from '../../../data/thunks';
export const CERT_STATUS_TYPE = {
EARNED_NOT_AVAILABLE: 'earned_but_not_available',
DOWNLOADABLE: 'downloadable',
REQUESTING: 'requesting',
UNVERIFIED: 'unverified',
};
function CertificateStatusAlert({ intl, payload }) {
const dispatch = useDispatch();
const {
certificateAvailableDate,
certStatus,
courseEndDate,
courseId,
certURL,
isWebCert,
userTimezone,
org,
notPassingCourseEnded,
tabs,
} = payload;
// eslint-disable-next-line react/prop-types
const AlertWrapper = (props) => props.children(props);
const sendAlertClickTracking = (id) => {
const { administrator } = getAuthenticatedUser();
sendTrackEvent(id, {
org_key: org,
courserun_key: courseId,
is_staff: administrator,
});
};
const renderCertAwardedStatus = () => {
const alertProps = {
variant: 'success',
icon: faCheckCircle,
iconClassName: 'alert-icon text-success-500',
};
if (certStatus === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE) {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const certificateAvailableDateFormatted = <FormattedDate value={certificateAvailableDate} day="numeric" month="long" year="numeric" />;
const courseEndDateFormatted = <FormattedDate value={courseEndDate} day="numeric" month="long" year="numeric" />;
alertProps.header = intl.formatMessage(certMessages.certStatusEarnedNotAvailableHeader);
alertProps.body = (
<p>
<FormattedMessage
id="learning.outline.alert.cert.earnedNotAvailable"
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and any earned certificates are
scheduled to be available after {certificateAvailableDate}."
values={{
courseEndDateFormatted,
certificateAvailableDate: certificateAvailableDateFormatted,
}}
{...timezoneFormatArgs}
/>
</p>
);
} else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) {
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
if (isWebCert) {
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
} else {
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.downloadableButton);
}
alertProps.buttonVisible = true;
alertProps.buttonLink = certURL;
alertProps.buttonAction = () => {
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_downloadable_button.clicked');
};
} else if (certStatus === CERT_STATUS_TYPE.REQUESTING) {
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.requestableButton);
alertProps.buttonVisible = true;
alertProps.buttonLink = '';
alertProps.buttonAction = () => {
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked');
dispatch(requestCert(courseId));
};
}
return alertProps;
};
const renderNotIDVerifiedStatus = () => {
const alertProps = {
variant: 'warning',
icon: faExclamationTriangle,
iconClassName: 'alert-icon text-warning-500',
header: intl.formatMessage(certStatusMessages.unverifiedHomeHeader),
buttonMessage: intl.formatMessage(certStatusMessages.unverifiedHomeButton),
body: intl.formatMessage(certStatusMessages.unverifiedHomeBody),
buttonVisible: true,
buttonLink: getConfig().SUPPORT_URL_ID_VERIFICATION,
buttonAction: () => {
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked');
},
};
return alertProps;
};
const renderNotPassingCourseEnded = () => {
const progressTab = tabs.find(tab => tab.slug === 'progress');
const progressLink = progressTab && progressTab.url;
const alertProps = {
header: intl.formatMessage(certMessages.certStatusNotPassingHeader),
buttonMessage: intl.formatMessage(certMessages.certStatusNotPassingButton),
body: intl.formatMessage(certStatusMessages.notPassingBody),
buttonVisible: true,
buttonLink: progressLink,
buttonAction: () => {
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_view_grades_button.clicked');
},
};
return alertProps;
};
let alertProps = {};
switch (certStatus) {
case CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE:
case CERT_STATUS_TYPE.DOWNLOADABLE:
case CERT_STATUS_TYPE.REQUESTING:
alertProps = renderCertAwardedStatus();
break;
case CERT_STATUS_TYPE.UNVERIFIED:
alertProps = renderNotIDVerifiedStatus();
break;
default:
if (notPassingCourseEnded) {
alertProps = renderNotPassingCourseEnded();
}
break;
}
return (
<AlertWrapper {...alertProps}>
{({
variant,
buttonVisible,
iconClassName,
icon,
header,
body,
buttonAction,
buttonLink,
buttonMessage,
}) => (
<Alert variant={variant}>
<div className="d-flex flex-column flex-lg-row justify-content-between align-items-center">
<div className={buttonVisible ? 'col-lg-8' : 'col-auto'}>
<FontAwesomeIcon icon={icon} className={iconClassName} />
<Alert.Heading>{header}</Alert.Heading>
{body}
</div>
{buttonVisible && (
<div className="flex-grow-0 pt-3 pt-lg-0">
<Button
variant="primary"
href={buttonLink}
onClick={() => {
if (buttonAction) { buttonAction(); }
}}
>
{buttonMessage}
</Button>
</div>
)}
</div>
</Alert>
)}
</AlertWrapper>
);
}
CertificateStatusAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
certificateAvailableDate: PropTypes.string,
certStatus: PropTypes.string,
courseEndDate: PropTypes.string,
courseId: PropTypes.string,
certURL: PropTypes.string,
isWebCert: PropTypes.bool,
userTimezone: PropTypes.string,
org: PropTypes.string,
notPassingCourseEnded: PropTypes.bool,
tabs: PropTypes.arrayOf(PropTypes.shape({
tab_id: PropTypes.string,
title: PropTypes.string,
url: PropTypes.string,
})),
}).isRequired,
};
export default injectIntl(CertificateStatusAlert);

View File

@@ -0,0 +1,107 @@
import React, { useMemo } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useAlert } from '../../../../generic/user-messages';
import { useModel } from '../../../../generic/model-store';
import { CERT_STATUS_TYPE } from './CertificateStatusAlert';
const CertificateStatusAlert = React.lazy(() => import('./CertificateStatusAlert'));
function verifyCertStatusType(status) {
switch (status) {
case CERT_STATUS_TYPE.DOWNLOADABLE:
case CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE:
case CERT_STATUS_TYPE.REQUESTING:
case CERT_STATUS_TYPE.UNVERIFIED:
return true;
default:
return false;
}
}
function useCertificateStatusAlert(courseId) {
const VERIFIED_MODES = {
PROFESSIONAL: 'professional',
VERIFIED: 'verified',
NO_ID_PROFESSIONAL_MODE: 'no-id-professional',
CREDIT_MODE: 'credit',
MASTERS: 'masters',
EXECUTIVE_EDUCATION: 'executive-education',
};
const {
isEnrolled,
org,
tabs,
} = useModel('courseHomeMeta', courseId);
const {
datesWidget: {
courseDateBlocks,
},
certData,
hasEnded,
userHasPassingGrade,
userTimezone,
enrollmentMode,
} = useModel('outline', courseId);
const {
certStatus,
certWebViewUrl,
certificateAvailableDate,
downloadUrl,
} = certData || {};
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
const isWebCert = downloadUrl === null;
const isVerifiedEnrollmentMode = (
enrollmentMode !== null
&& enrollmentMode !== undefined
&& !!Object.values(VERIFIED_MODES).find(mode => mode === enrollmentMode)
);
let certURL = '';
if (certWebViewUrl) {
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
} else if (downloadUrl) {
// PDF Certificate
certURL = downloadUrl;
}
const hasAlertingCertStatus = verifyCertStatusType(certStatus);
// Only show if:
// - there is a known cert status that we want provide status on.
// - Or the course has ended and the learner does not have a passing grade.
const isVisible = isEnrolled && hasAlertingCertStatus;
const notPassingCourseEnded = (
isEnrolled
&& isVerifiedEnrollmentMode
&& !hasAlertingCertStatus
&& hasEnded
&& !userHasPassingGrade
);
const payload = {
certificateAvailableDate,
certURL,
certStatus,
courseId,
courseEndDate: endBlock && endBlock.date,
userTimezone,
isWebCert,
org,
notPassingCourseEnded,
tabs,
};
useAlert(isVisible || notPassingCourseEnded, {
code: 'clientCertificateStatusAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',
});
return {
clientCertificateStatusAlert: CertificateStatusAlert,
};
}
export default useCertificateStatusAlert;

View File

@@ -0,0 +1 @@
export { default } from './hooks';

Some files were not shown because too many files have changed in this diff Show More