Compare commits

..

221 Commits

Author SHA1 Message Date
Diana Catalina Olarte
8e1904a235 fix: show site name instead of edX
(cherry picked from commit cafb881a61)
2022-05-18 15:52:10 +01:00
Michael Terry
88485a0f77 fix: add back es-check & fsevents for now to fix build
A previous commit (7f37575) dropped es-check, which dropped
fsevents, which caused our build system (which is still using
npm@6) to fail with an error like `Unsupported platform for
fsevents` when trying to install fsevents through a dependency
(e.g. when installing npm aliases).

I am reintroducing all the package-lock changes from that commit
to get back fsevents in a state where that error does not occur.

I think a longer-term fix would be to instead upgrade our build
system to node16 / npm6. But this is an easy fix for now to unblock
the builds.
2022-04-14 10:16:14 -04:00
edx-semantic-release
361a099ed1 chore(i18n): update translations 2022-04-10 17:08:59 -04:00
Adam Stankiewicz
7f3757539a build: use shared browserslist config and remove is-es5 check 2022-04-08 16:30:25 -04:00
Michael Terry
44f5132e2a fix: downgrade react and upgrade some other deps to align
Now that we are using node 16, peer dependencies are much more
strict about aligning between all of our dependencies.

This PR downgrades react from 17 to 16 (no changes) and upgrades
paragon and frontend-lib-special-exams to all be on the same
page about what peer dependency ranges are valid.
2022-04-08 16:00:29 -04:00
Renovate Bot
53b19c9be3 chore(deps): update codecov/codecov-action action to v3 2022-04-07 15:24:08 -04:00
edX requirements bot
abc374b60a chore!: Dropped support for Node 12 2022-04-07 15:15:04 -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
241 changed files with 45429 additions and 10154 deletions

11
.env
View File

@@ -2,14 +2,20 @@
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='production'
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=''
@@ -19,12 +25,13 @@ LOGOUT_URL=''
LOGO_URL=''
LOGO_TRADEMARK_URL=''
LOGO_WHITE_URL=''
FAVICON_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=''
@@ -36,5 +43,3 @@ TERMS_OF_SERVICE_URL=''
TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
SESSION_COOKIE_DOMAIN=''
ENABLE_NOTICES=''

View File

@@ -2,14 +2,20 @@
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='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'
@@ -18,7 +24,7 @@ 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
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
@@ -37,4 +43,3 @@ TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost'
ENABLE_NOTICES=''

View File

@@ -2,14 +2,20 @@
# 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='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'
@@ -18,7 +24,7 @@ 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
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
@@ -36,4 +42,3 @@ 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'
ENABLE_NOTICES=''

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

View File

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

1
.husky/_/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*

31
.husky/_/husky.sh Normal file
View File

@@ -0,0 +1,31 @@
#!/bin/sh
if [ -z "$husky_skip_init" ]; then
debug () {
if [ "$HUSKY_DEBUG" = "1" ]; then
echo "husky (debug) - $1"
fi
}
readonly hook_name="$(basename "$0")"
debug "starting $hook_name..."
if [ "$HUSKY" = "0" ]; then
debug "HUSKY env variable is set to 0, skipping hook"
exit 0
fi
if [ -f ~/.huskyrc ]; then
debug "sourcing ~/.huskyrc"
. ~/.huskyrc
fi
export readonly husky_skip_init=1
sh -e "$0" "$@"
exitCode="$?"
if [ $exitCode != 0 ]; then
echo "husky - $hook_name hook exited with code $exitCode (error)"
fi
exit $exitCode
fi

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

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,15 +36,15 @@ 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:
@@ -60,7 +58,6 @@ validate:
npm run lint -- --max-warnings 0
npm run test
npm run build
npm run is-es5
.PHONY: validate.ci
validate.ci:

View File

@@ -71,6 +71,15 @@ as documented in the Open edX Developer Guide under
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.
@@ -109,3 +118,4 @@ TWITTER_URL
unless this is set. Optional.
Example: https://twitter.com/edXOnline

44519
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,13 +7,11 @@
"url": "git+https://github.com/edx/frontend-app-learning.git"
},
"browserslist": [
"last 2 versions",
"ie 11"
"extends @edx/browserslist-config"
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"is-es5": "es-check es5 ./dist/*.js",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"prepare": "husky install",
@@ -32,51 +30,49 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.1.6",
"@edx/frontend-enterprise-utils": "1.0.0",
"@edx/frontend-lib-special-exams": "1.13.3",
"@edx/frontend-platform": "1.12.7",
"@edx/paragon": "16.13.3",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@edx/frontend-component-footer": "10.2.2",
"@edx/frontend-component-header": "2.4.6",
"@edx/frontend-lib-special-exams": "1.16.3",
"@edx/frontend-platform": "1.15.6",
"@edx/paragon": "19.14.1",
"@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.15",
"@pact-foundation/pact": "9.16.3",
"@reduxjs/toolkit": "1.6.2",
"@fortawesome/react-fontawesome": "0.1.18",
"@popperjs/core": "2.11.5",
"@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.1",
"core-js": "3.16.4",
"js-cookie": "2.2.1",
"core-js": "3.21.1",
"js-cookie": "3.0.1",
"lodash.camelcase": "4.3.0",
"prop-types": "15.7.2",
"react": "17.0.2",
"react-break": "1.3.2",
"react-dom": "17.0.2",
"prop-types": "15.8.1",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-helmet": "6.1.0",
"react-redux": "7.2.5",
"react-redux": "7.2.8",
"react-router": "5.2.1",
"react-router-dom": "5.2.1",
"react-router-dom": "5.3.0",
"react-share": "4.4.0",
"redux": "4.1.1",
"redux": "4.1.2",
"regenerator-runtime": "0.13.9",
"reselect": "4.0.0",
"reselect": "4.1.5",
"truncate-html": "1.0.4",
"util": "0.12.4"
},
"devDependencies": {
"@edx/frontend-build": "8.0.4",
"@testing-library/dom": "7.16.3",
"@testing-library/jest-dom": "5.14.1",
"@testing-library/react": "10.3.0",
"@testing-library/user-event": "13.2.1",
"@edx/browserslist-config": "1.0.2",
"@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.0.0",
"glob": "7.1.7",
"husky": "7.0.2",
"jest": "27.0.6",
"jest-chain": "1.1.5",
"reactifex": "1.1.1",
"es-check": "6.2.1",
"husky": "7.0.4",
"jest": "27.5.1",
"rosie": "2.1.0"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -65,6 +65,7 @@ function AccessExpirationAlert({ intl, payload }) {
<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
@@ -97,6 +98,7 @@ function AccessExpirationAlert({ intl, payload }) {
<FormattedMessage
id="learning.accessExpiration.header"
defaultMessage="Audit Access Expires {date}"
description="Headline for auditing deadline"
values={{
date: (
<FormattedDate
@@ -115,6 +117,7 @@ function AccessExpirationAlert({ intl, payload }) {
<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

View File

@@ -16,6 +16,7 @@ function AccessExpirationMasqueradeBanner({ payload }) {
<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"

View File

@@ -4,6 +4,7 @@ const messages = defineMessages({
upgradeNow: {
id: 'learning.accessExpiration.upgradeNow',
defaultMessage: 'Upgrade now',
description: 'The anchor text for the upgrading link',
},
});

View File

@@ -86,6 +86,7 @@ function CourseStartAlert({ payload }) {
<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>
);

View File

@@ -22,6 +22,7 @@ function CourseStartMasqueradeBanner({ payload }) {
<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"

View File

@@ -9,10 +9,13 @@ import {
Icon,
} from '@edx/paragon';
import { Check, ArrowForward } from '@edx/paragon/icons';
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { sendActivationEmail } from '../../courseware/data';
import messages from './messages';
function AccountActivationAlert() {
function AccountActivationAlert({
intl,
}) {
const [showModal, setShowModal] = useState(false);
const [showSpinner, setShowSpinner] = useState(false);
const [showCheck, setShowCheck] = useState(false);
@@ -29,22 +32,12 @@ function AccountActivationAlert() {
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 infinit rendering
// of cookie would make it infinite rendering
if (Cookies.get('show-account-activation-popup') === undefined) {
setShowModal(true);
}
}
const title = (
<h3>
<FormattedMessage
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"
/>
</h3>
);
const button = (
<Button
variant="primary"
@@ -64,7 +57,7 @@ function AccountActivationAlert() {
);
const children = () => {
let bodyContent = null;
let bodyContent;
const message = (
<FormattedMessage
id="account-activation.alert.message"
@@ -123,7 +116,7 @@ function AccountActivationAlert() {
return (
<AlertModal
isOpen={showModal}
title={title}
title={intl.formatMessage(messages.accountActivationAlertTitle)}
footerNode={button}
onClose={() => ({})}
>
@@ -132,4 +125,8 @@ function AccountActivationAlert() {
);
}
AccountActivationAlert.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(AccountActivationAlert);

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;

View File

@@ -1,34 +0,0 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import genericMessages from '../generic/messages';
function AnonymousUserMenu({ intl }) {
return (
<div>
<Button
className="mr-3"
variant="outline-primary"
href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
>
{intl.formatMessage(genericMessages.registerSentenceCase)}
</Button>
<Button
variant="primary"
href={`${getLoginRedirectUrl(global.location.href)}`}
>
{intl.formatMessage(genericMessages.signInSentenceCase)}
</Button>
</div>
);
}
AnonymousUserMenu.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(AnonymousUserMenu);

View File

@@ -1,76 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon';
import messages from './messages';
function AuthenticatedUserDropdown({ enterpriseLearnerPortalLink, intl, username }) {
let dashboardMenuItem = (
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
{intl.formatMessage(messages.dashboard)}
</Dropdown.Item>
);
if (enterpriseLearnerPortalLink && Object.keys(enterpriseLearnerPortalLink).length > 0) {
dashboardMenuItem = (
<Dropdown.Item href={enterpriseLearnerPortalLink.href}>
{enterpriseLearnerPortalLink.content}
</Dropdown.Item>
);
}
return (
<>
<a className="text-gray-700 mr-3" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
<Dropdown className="user-dropdown">
<Dropdown.Toggle variant="outline-primary">
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
<span data-hj-suppress className="d-none d-md-inline">
{username}
</span>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">
{dashboardMenuItem}
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${username}`}>
{intl.formatMessage(messages.profile)}
</Dropdown.Item>
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>
{intl.formatMessage(messages.account)}
</Dropdown.Item>
{!enterpriseLearnerPortalLink && (
// Users should only see Order History if they do not have an available
// learner portal, because an available learner portal currently means
// that they access content via Subscriptions, in which context an "order"
// is not relevant.
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>
{intl.formatMessage(messages.orderHistory)}
</Dropdown.Item>
)}
<Dropdown.Item href={getConfig().LOGOUT_URL}>
{intl.formatMessage(messages.signOut)}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</>
);
}
AuthenticatedUserDropdown.propTypes = {
intl: intlShape.isRequired,
username: PropTypes.string.isRequired,
enterpriseLearnerPortalLink: PropTypes.shape({
type: PropTypes.string,
href: PropTypes.string,
content: PropTypes.string,
}),
};
AuthenticatedUserDropdown.defaultProps = {
enterpriseLearnerPortalLink: undefined,
};
export default injectIntl(AuthenticatedUserDropdown);

View File

@@ -1,99 +0,0 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { useEnterpriseConfig } from '@edx/frontend-enterprise-utils';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import AnonymousUserMenu from './AnonymousUserMenu';
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
import messages from './messages';
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,
};
function Header({
courseOrg, courseNumber, courseTitle, intl, showUserDropdown,
}) {
const { authenticatedUser } = useContext(AppContext);
const { enterpriseLearnerPortalLink, enterpriseCustomerBrandingConfig } = useEnterpriseConfig(
authenticatedUser,
getConfig().ENTERPRISE_LEARNER_PORTAL_HOSTNAME,
getConfig().LMS_BASE_URL,
);
let headerLogo = (
<LinkedLogo
className="logo"
href={`${getConfig().LMS_BASE_URL}/dashboard`}
src={getConfig().LOGO_URL}
alt={getConfig().SITE_NAME}
/>
);
if (enterpriseCustomerBrandingConfig && Object.keys(enterpriseCustomerBrandingConfig).length > 0) {
headerLogo = (
<LinkedLogo
className="logo"
href={enterpriseCustomerBrandingConfig.logoDestination}
src={enterpriseCustomerBrandingConfig.logo}
alt={enterpriseCustomerBrandingConfig.logoAltText}
/>
);
}
return (
<header className="course-header">
<a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a>
<div className="container-xl py-2 d-flex align-items-center">
{headerLogo}
<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>
{showUserDropdown && authenticatedUser && (
<AuthenticatedUserDropdown
enterpriseLearnerPortalLink={enterpriseLearnerPortalLink}
username={authenticatedUser.username}
/>
)}
{showUserDropdown && !authenticatedUser && (
<AnonymousUserMenu />
)}
</div>
</header>
);
}
Header.propTypes = {
courseOrg: PropTypes.string,
courseNumber: PropTypes.string,
courseTitle: PropTypes.string,
intl: intlShape.isRequired,
showUserDropdown: PropTypes.bool,
};
Header.defaultProps = {
courseOrg: null,
courseNumber: null,
courseTitle: null,
showUserDropdown: true,
};
export default injectIntl(Header);

View File

@@ -1,29 +0,0 @@
import React from 'react';
import {
authenticatedUser, initializeMockApp, render, screen,
} from '../setupTest';
import { Header } from './index';
describe('Header', () => {
beforeAll(async () => {
// We need to mock AuthService to implicitly use `getAuthenticatedUser` within `AppContext.Provider`.
await initializeMockApp();
});
it('displays user button', () => {
render(<Header />);
expect(screen.getByRole('button')).toHaveTextContent(authenticatedUser.username);
});
it('displays course data', () => {
const courseData = {
courseOrg: 'course-org',
courseNumber: 'course-number',
courseTitle: 'course-title',
};
render(<Header {...courseData} />);
expect(screen.getByText(`${courseData.courseOrg} ${courseData.courseNumber}`)).toBeInTheDocument();
expect(screen.getByText(courseData.courseTitle)).toBeInTheDocument();
});
});

View File

@@ -1,46 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
courseMaterial: {
id: 'learn.navigation.course.tabs.label',
defaultMessage: 'Course Material',
description: 'The accessible label for course tabs navigation',
},
dashboard: {
id: 'header.menu.dashboard.label',
defaultMessage: 'Dashboard',
description: 'The text for the user menu Dashboard navigation link.',
},
help: {
id: 'header.help.label',
defaultMessage: 'Help',
description: 'The text for the link to the Help Center',
},
profile: {
id: 'header.menu.profile.label',
defaultMessage: 'Profile',
description: 'The text for the user menu Profile navigation link.',
},
account: {
id: 'header.menu.account.label',
defaultMessage: 'Account',
description: 'The text for the user menu Account navigation link.',
},
orderHistory: {
id: 'header.menu.orderHistory.label',
defaultMessage: 'Order History',
description: 'The text for the user menu Order History navigation link.',
},
skipNavLink: {
id: 'header.navigation.skipNavLink',
defaultMessage: 'Skip to main content.',
description: 'A link used by screen readers to allow users to skip to the main content of the page.',
},
signOut: {
id: 'header.menu.signOut.label',
defaultMessage: 'Sign Out',
description: 'The label for the user menu Sign Out action.',
},
});
export default messages;

View File

@@ -1,5 +1,4 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import courseMetadataBase from '../../../shared/data/__factories__/courseMetadataBase.factory';
Factory.define('courseHomeMetadata')
@@ -9,7 +8,9 @@ Factory.define('courseHomeMetadata')
title: 'Demonstration Course',
is_self_paced: false,
is_enrolled: false,
can_load_courseware: false,
is_staff: false,
can_load_courseware: true,
celebrations: null,
course_access: {
additional_context_user_message: null,
developer_message: null,
@@ -18,6 +19,106 @@ Factory.define('courseHomeMetadata')
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

@@ -40,6 +40,9 @@ Factory.define('outlineTabData')
course_goals: {
goal_options: [],
selected_goal: null,
weekly_learning_goal_enabled: false,
days_per_week: null,
subscribed_to_reminders: null,
},
course_tools: [
{

View File

@@ -17,6 +17,7 @@ Factory.define('progressTabData')
percent: 1,
is_passing: true,
},
credit_course_requirements: null,
section_scores: [
{
display_name: 'First section',

View File

@@ -5,7 +5,6 @@ Factory.define('upgradeNotificationData')
.option('dateBlocks', [])
.option('offer', null)
.option('userTimezone', null)
.option('accessExpiration', null)
.option('contentTypeGatingEnabled', false)
.attr('courseId', 'course-v1:edX+DemoX+Demo_Course')
.attr('upsellPageName', 'test')
@@ -18,4 +17,9 @@ Factory.define('upgradeNotificationData')
upgradeUrl: `${host}/dashboard`,
}))
.attr('org', 'edX')
.attrs({
accessExpiration: {
expiration_date: '1950-07-13T02:04:49.040006Z',
},
})
.attr('timeOffsetMillis', 0);

View File

@@ -3,8 +3,9 @@
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
@@ -14,12 +15,14 @@ Object {
"courseId": null,
"courseStatus": "loading",
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"canLoadCourseware": false,
"course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true,
"celebrations": null,
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
@@ -28,7 +31,7 @@ Object {
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course_1",
"id": "course-v1:edX+DemoX+Demo_Course",
"isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false,
@@ -41,45 +44,49 @@ Object {
Object {
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/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": 10,
"upgradeUrl": "test",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
"dates": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"courseDateBlocks": Array [
Object {
"date": "2020-05-01T17:59:41Z",
@@ -293,7 +300,7 @@ Object {
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
"hasEnded": false,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"id": "course-v1:edX+DemoX+Demo_Course",
"learnerIsFullAccess": true,
},
},
@@ -301,14 +308,22 @@ Object {
"recommendations": Object {
"recommendationsStatus": "loading",
},
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
}
`;
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
@@ -318,12 +333,14 @@ Object {
"courseId": null,
"courseStatus": "loading",
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"canLoadCourseware": false,
"course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true,
"celebrations": null,
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
@@ -332,7 +349,7 @@ Object {
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course_1",
"id": "course-v1:edX+DemoX+Demo_Course",
"isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false,
@@ -345,45 +362,49 @@ Object {
Object {
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/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": 10,
"upgradeUrl": "test",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
"outline": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"accessExpiration": null,
"canShowUpgradeSock": false,
"certData": Object {
@@ -396,7 +417,7 @@ Object {
"courses": Object {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"id": "course-v1:edX+DemoX+Demo_Course",
"sectionIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
],
@@ -406,7 +427,7 @@ Object {
"sections": Object {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
"complete": false,
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseId": "course-v1:edX+DemoX+Demo_Course",
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"resumeBlock": false,
"sequenceIds": Array [
@@ -432,8 +453,11 @@ Object {
},
},
"courseGoals": Object {
"daysPerWeek": null,
"goalOptions": Array [],
"selectedGoal": null,
"subscribedToReminders": null,
"weeklyLearningGoalEnabled": false,
},
"courseTools": Array [
Object {
@@ -450,6 +474,7 @@ Object {
"datesWidget": Object {
"courseDateBlocks": Array [],
},
"enableProctoredExams": undefined,
"enrollAlert": Object {
"canEnroll": true,
"extraText": "Contact the administrator.",
@@ -458,7 +483,7 @@ Object {
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
"hasEnded": undefined,
"hasScheduledContent": null,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"id": "course-v1:edX+DemoX+Demo_Course",
"offer": null,
"resumeCourse": Object {
"hasVisitedCourse": false,
@@ -481,14 +506,22 @@ Object {
"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_1",
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
@@ -498,12 +531,14 @@ Object {
"courseId": null,
"courseStatus": "loading",
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"canLoadCourseware": false,
"course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true,
"celebrations": null,
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
@@ -512,7 +547,7 @@ Object {
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course_1",
"id": "course-v1:edX+DemoX+Demo_Course",
"isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false,
@@ -525,45 +560,49 @@ Object {
Object {
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/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": 10,
"upgradeUrl": "test",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
"progress": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"accessExpiration": null,
"certificateData": Object {},
"completionSummary": Object {
@@ -575,9 +614,9 @@ Object {
"isPassing": true,
"letterGrade": "pass",
"percent": 1,
"visiblePercent": 1,
},
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseId": "course-v1:edX+DemoX+Demo_Course",
"creditCourseRequirements": null,
"end": "3027-03-31T00:00:00Z",
"enrollmentMode": "audit",
"gradesFeatureIsFullyLocked": false,
@@ -585,7 +624,7 @@ Object {
"gradingPolicy": Object {
"assignmentPolicies": Array [
Object {
"averageGrade": 1,
"averageGrade": "1.00",
"numDroppable": 1,
"shortLabel": "HW",
"type": "Homework",
@@ -598,7 +637,7 @@ Object {
},
},
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"id": "course-v1:edX+DemoX+Demo_Course",
"sectionScores": Array [
Object {
"displayName": "First section",
@@ -669,5 +708,12 @@ Object {
"recommendations": Object {
"recommendationsStatus": "loading",
},
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
}
`;

View File

@@ -15,7 +15,10 @@ const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) =
let averageGrade = 0;
let weightedGrade = 0;
if (points.length) {
averageGrade = points.reduce((a, b) => a + b, 0) / 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 };
@@ -87,14 +90,21 @@ function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
});
}
function normalizeCourseHomeCourseMetadata(metadata) {
/**
* 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. We switch it to "outline" here for
// 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' ? 'outline' : tab.tabId,
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId,
title: tab.title,
url: tab.url,
})),
@@ -179,11 +189,11 @@ export function normalizeOutlineBlocks(courseId, blocks) {
return models;
}
export async function getCourseHomeCourseMetadata(courseId) {
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);
return normalizeCourseHomeCourseMetadata(data, rootSlug);
}
// For debugging purposes, you might like to see a fully loaded dates tab.
@@ -229,16 +239,6 @@ export async function getProgressTabData(courseId, targetUserId) {
camelCasedData.sectionScores,
);
// Accumulate the weighted grades by assignment type to calculate the learner facing grade. The grades within
// assignmentPolicies have been filtered by what's visible to the learner.
camelCasedData.courseGrade.visiblePercent = camelCasedData.gradingPolicy.assignmentPolicies
? camelCasedData.gradingPolicy.assignmentPolicies.reduce(
(accumulator, assignment) => accumulator + assignment.weightedGrade, 0,
) : camelCasedData.courseGrade.percent;
camelCasedData.courseGrade.isPassing = camelCasedData.courseGrade.visiblePercent
>= Math.min(...Object.values(data.grading_policy.grade_range));
// 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.
@@ -343,6 +343,7 @@ export async function getOutlineTabData(courseId) {
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;
@@ -366,6 +367,7 @@ export async function getOutlineTabData(courseId) {
datesWidget,
enrollAlert,
enrollmentMode,
enableProctoredExams,
handoutsHtml,
hasScheduledContent,
hasEnded,
@@ -386,11 +388,20 @@ export async function postCourseDeadlines(courseId, model) {
});
}
export async function postCourseGoals(courseId, goalKey) {
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 });

View File

@@ -3,7 +3,8 @@ export {
fetchOutlineTab,
fetchProgressTab,
resetDeadlines,
saveCourseGoal,
deprecatedSaveCourseGoal,
saveWeeklyLearningGoal,
} from './thunks';
export { reducer } from './slice';

View File

@@ -139,7 +139,7 @@ describe('Course Home Service', () => {
title: 'Demonstration Course',
username: 'edx',
};
const response = await getCourseHomeCourseMetadata(courseId);
const response = await getCourseHomeCourseMetadata(courseId, 'outline');
expect(response).toBeTruthy();
expect(response).toEqual(normalizedTabData);
});

View File

@@ -136,7 +136,7 @@ describe('Data layer integration tests', () => {
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
axiosMock.onPost(goalUrl).reply(200, {});
await thunks.saveCourseGoal(courseId, 'unsure');
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"}`);

View File

@@ -11,11 +11,15 @@ const slice = createSlice({
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;
@@ -47,6 +51,7 @@ const slice = createSlice({
});
export const {
fetchProctoringInfoResolved,
fetchTabDenied,
fetchTabFailure,
fetchTabRequest,

View File

@@ -7,7 +7,8 @@ import {
getOutlineTabData,
getProgressTabData,
postCourseDeadlines,
postCourseGoals,
deprecatedPostCourseGoals,
postWeeklyLearningGoal,
postDismissWelcomeMessage,
postRequestCert,
} from './api';
@@ -32,7 +33,7 @@ export function fetchTab(courseId, tab, getTabData, targetUserId) {
return async (dispatch) => {
dispatch(fetchTabRequest({ courseId }));
Promise.allSettled([
getCourseHomeCourseMetadata(courseId),
getCourseHomeCourseMetadata(courseId, 'outline'),
getTabData(courseId, targetUserId),
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
@@ -109,8 +110,12 @@ export function resetDeadlines(courseId, model, getTabData) {
};
}
export async function saveCourseGoal(courseId, goalKey) {
return postCourseGoals(courseId, goalKey);
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) {

View File

@@ -51,7 +51,7 @@ describe('DatesTab', () => {
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
function setMetadata(attributes, options) {
courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
}
@@ -140,7 +140,7 @@ describe('DatesTab', () => {
userEvent.hover(tipIcon);
const tooltip = screen.getByText(tipText); // now it's there
userEvent.unhover(tipIcon);
waitForElementToBeRemoved(tooltip); // and it's gone again
await waitForElementToBeRemoved(tooltip); // and it's gone again
});
});

View File

@@ -4,30 +4,37 @@ const messages = defineMessages({
completed: {
id: 'learning.dates.badge.completed',
defaultMessage: 'Completed',
description: 'shown as label for the assignments which learner has completed.',
},
dueNext: {
id: 'learning.dates.badge.dueNext',
defaultMessage: 'Due next',
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.',
},
});

View File

@@ -1,8 +1,9 @@
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 { Header } from '../../course-header';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import PageLoading from '../../generic/PageLoading';
import { unsubscribeFromCourseGoal } from '../data/api';
@@ -28,6 +29,10 @@ function GoalUnsubscribe({ intl }) {
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 (

View File

@@ -36,7 +36,9 @@ function ResultPage({ courseTitle, error, intl }) {
<>
<UnsubscribeIcon className="text-primary" alt="" />
<div role="heading" aria-level="1" className="h2">{header}</div>
<div>{description}</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>

View File

@@ -4,26 +4,32 @@ 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',
},
});

View File

@@ -40,7 +40,7 @@ export default function DateSummary({
};
return (
<li className="container p-0 mb-3 small text-dark-500">
<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">

View File

@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
@@ -13,22 +13,40 @@ export default function LmsHtmlFragment({
<html>
<head>
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
<link rel="stylesheet" href="/static/css/bootstrap/lms-main.css">
<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 handleLoad() {
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
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={handleLoad}
onLoad={resetIframeHeight}
ref={iframe}
referrerPolicy="origin"
scrolling="no"

View File

@@ -1,22 +1,21 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Toast } from '@edx/paragon';
import { Button } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates';
import CourseGoalCard from './widgets/CourseGoalCard';
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 genericMessages from '../../generic/messages';
import messages from './messages';
import Section from './Section';
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpdateGoalSelector from './widgets/UpdateGoalSelector';
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
import useCertificateAvailableAlert from './alerts/certificate-status-alert';
@@ -35,13 +34,13 @@ import { initHomeMMP2P, MMP2PFlyover } from '../../experiments/mm-p2p';
function OutlineTab({ intl }) {
const {
courseId,
proctoringPanelStatus,
} = useSelector(state => state.courseHome);
const {
isSelfPaced,
org,
title,
username,
userTimezone,
} = useModel('courseHomeMeta', courseId);
@@ -52,24 +51,23 @@ function OutlineTab({ intl }) {
sections,
},
courseGoals: {
goalOptions,
selectedGoal,
weeklyLearningGoalEnabled,
} = {},
datesBannerInfo,
datesWidget: {
courseDateBlocks,
},
resumeCourse: {
hasVisitedCourse,
url: resumeCourseUrl,
},
enableProctoredExams,
offer,
timeOffsetMillis,
verifiedMode,
} = useModel('outline', courseId);
const [courseGoalToDisplay, setCourseGoalToDisplay] = useState(selectedGoal);
const [goalToastHeader, setGoalToastHeader] = useState('');
const {
marketingUrl,
} = useModel('coursewareMeta', courseId);
const [expandAll, setExpandAll] = useState(false);
const eventProperties = {
@@ -77,14 +75,6 @@ function OutlineTab({ intl }) {
courserun_key: courseId,
};
const logResumeCourseClick = () => {
sendTrackingLogEvent('edx.course.home.resume_course.clicked', {
...eventProperties,
event_type: hasVisitedCourse ? 'resume' : 'start',
url: resumeCourseUrl,
});
};
// Below the course title alerts (appearing in the order listed here)
const courseStartAlert = useCourseStartAlert(courseId);
const courseEndAlert = useCourseEndAlert(courseId);
@@ -121,24 +111,10 @@ function OutlineTab({ intl }) {
return (
<>
<Toast
closeLabel={intl.formatMessage(genericMessages.close)}
onClose={() => setGoalToastHeader('')}
show={!!(goalToastHeader)}
>
{goalToastHeader}
</Toast>
<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>
{resumeCourseUrl && (
<div className="col-12 col-sm-auto p-0">
<Button variant="brand" block href={resumeCourseUrl} onClick={() => logResumeCourseClick()}>
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
</Button>
</div>
)}
</div>
{/** [MM-P2P] Experiment (className for optimizely trigger) */}
<div className="row course-outline-tab">
@@ -172,26 +148,18 @@ function OutlineTab({ intl }) {
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} />
</>
)}
{!courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
<CourseGoalCard
courseId={courseId}
goalOptions={goalOptions}
title={title}
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
/>
)}
<StartOrResumeCourseCard />
<WelcomeMessage courseId={courseId} />
{rootCourseId && (
<>
<div className="row w-100 m-0 mb-3 justify-content-end">
<div className="col-12 col-sm-auto p-0">
<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 className="list-unstyled">
<ol id="courseHome-outline" className="list-unstyled">
{courses[rootCourseId].sectionIds.map((sectionId) => (
<Section
key={sectionId}
@@ -207,22 +175,16 @@ function OutlineTab({ intl }) {
</div>
{rootCourseId && (
<div className="col col-12 col-md-4">
<ProctoringInfoPanel
courseId={courseId}
username={username}
/>
{courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
<UpdateGoalSelector
courseId={courseId}
goalOptions={goalOptions}
selectedGoal={courseGoalToDisplay}
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
<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
courseId={courseId}
/>
<CourseTools />
{ /** [MM-P2P] Experiment (conditional) */ }
{ MMP2P.state.isEnabled
? <MMP2PFlyover isStatic options={MMP2P} />
@@ -232,6 +194,7 @@ function OutlineTab({ intl }) {
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="course_home"
userTimezone={userTimezone}
shouldDisplayBorder
@@ -241,13 +204,10 @@ function OutlineTab({ intl }) {
/>
)}
<CourseDates
courseId={courseId}
/** [MM-P2P] Experiment */
mmp2p={MMP2P}
/>
<CourseHandouts
courseId={courseId}
/>
<CourseHandouts />
</div>
)}
</div>

View File

@@ -1,3 +1,6 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
@@ -6,6 +9,7 @@ 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 {
@@ -24,21 +28,21 @@ jest.mock('@edx/frontend-platform/analytics');
describe('Outline Tab', () => {
let axiosMock;
const courseId = 'course-v1:edX+Test+run';
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)}`;
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', { id: courseId });
const defaultMetadata = Factory.build('courseHomeMetadata');
const defaultTabData = Factory.build('outlineTabData');
function setMetadata(attributes, options) {
const courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
const courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
}
@@ -73,7 +77,7 @@ describe('Outline Tab', () => {
describe('Course Outline', () => {
it('displays link to start course', async () => {
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: messages.start.defaultMessage })).toBeInTheDocument();
});
it('displays link to resume course', async () => {
@@ -328,89 +332,145 @@ describe('Outline Tab', () => {
});
});
describe('Course Goals', () => {
const goalOptions = [
['certify', 'Earn a certificate'],
['complete', 'Complete the course'],
['explore', 'Explore the course'],
['unsure', 'Not sure yet'],
];
it('does not render goal widgets if no goals available', async () => {
describe('Start or Resume Course Card', () => {
it('renders startOrResumeCourseCard', async () => {
await fetchAndRender();
expect(screen.queryByTestId('course-goal-card')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
expect(screen.queryByTestId('edit-goal-selector')).not.toBeInTheDocument();
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('goal is not set', () => {
describe('weekly learning goal is not set', () => {
beforeEach(async () => {
setTabData({
course_goals: {
goal_options: goalOptions,
selected_goal: null,
weekly_learning_goal_enabled: true,
},
});
await fetchAndRender();
});
it('renders goal card', () => {
expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
expect(screen.getByTestId('course-goal-card')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Complete the course' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Explore the course' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Not sure yet' })).toBeInTheDocument();
it('renders weekly learning goal card', async () => {
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
});
it('renders goal selector on goal selection', async () => {
const certifyGoalButton = screen.getByRole('button', { name: 'Earn a certificate' });
fireEvent.click(certifyGoalButton);
const goalSelector = await screen.findByTestId('edit-goal-selector');
expect(goalSelector).toBeInTheDocument();
});
});
describe('goal is set', () => {
beforeEach(async () => {
setTabData({
course_goals: {
goal_options: goalOptions,
selected_goal: { text: 'Earn a certificate', key: 'certify' },
},
});
await fetchAndRender();
it('disables the subscribe button if no goal is set', async () => {
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeDisabled();
});
it('renders edit goal selector', () => {
expect(screen.getByLabelText('Goal')).toBeInTheDocument();
expect(screen.getByTestId('edit-goal-selector')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
});
it('updates goal on click', async () => {
// Open dropdown
const dropdownButtonNode = screen.getByRole('button', { name: 'Earn a certificate' });
await waitFor(() => {
expect(dropdownButtonNode).toBeInTheDocument();
});
fireEvent.click(dropdownButtonNode);
// Select a new goal
const unsureButtonNode = screen.getByRole('button', { name: 'Not sure yet' });
await waitFor(() => {
expect(unsureButtonNode).toBeInTheDocument();
});
fireEvent.click(unsureButtonNode);
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);
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","goal_key":"unsure"}`);
// 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', () => {
@@ -616,7 +676,7 @@ describe('Outline Tab', () => {
],
});
await fetchAndRender();
expect(screen.queryByText('Your grade and certificate will be ready soon!')).toBeInTheDocument();
expect(screen.queryByText('Your grade and certificate status will be available soon.')).toBeInTheDocument();
});
it('renders verification alert', async () => {
const now = new Date();
@@ -650,7 +710,7 @@ describe('Outline Tab', () => {
],
});
await fetchAndRender();
expect(screen.queryByText('Verify your identity to earn a certificate!')).toBeInTheDocument();
expect(screen.queryByText('Verify your identity to qualify for a certificate.')).toBeInTheDocument();
});
it('renders non passing grade', async () => {
const now = new Date();
@@ -683,8 +743,8 @@ describe('Outline Tab', () => {
],
});
await fetchAndRender();
screen.getAllByText('You are not eligible for a certificate');
expect(screen.queryByText('You are not eligible for a certificate')).toBeInTheDocument();
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();
@@ -725,7 +785,7 @@ describe('Outline Tab', () => {
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
{
courserun_key: 'course-v1:edX+Test+run',
courserun_key: courseId,
is_staff: false,
org_key: 'edX',
});
@@ -769,7 +829,7 @@ describe('Outline Tab', () => {
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_downloadable_button.clicked',
{
courserun_key: 'course-v1:edX+Test+run',
courserun_key: courseId,
is_staff: false,
org_key: 'edX',
});
@@ -813,7 +873,7 @@ describe('Outline Tab', () => {
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
{
courserun_key: 'course-v1:edX+Test+run',
courserun_key: courseId,
is_staff: false,
org_key: 'edX',
});
@@ -856,7 +916,7 @@ describe('Outline Tab', () => {
],
});
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: messages.start.defaultMessage })).toBeInTheDocument();
expect(screen.queryByText('More content is coming soon!')).not.toBeInTheDocument();
});
});
@@ -1056,6 +1116,7 @@ describe('Outline Tab', () => {
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();
});
@@ -1169,7 +1230,7 @@ describe('Outline Tab', () => {
});
});
describe('Accont Activation Alert', () => {
describe('Account Activation Alert', () => {
beforeEach(() => {
const intersectionObserverMock = () => ({
observe: () => null,
@@ -1197,7 +1258,7 @@ describe('Outline Tab', () => {
expect(screen.queryByRole('button', { name: 'resend the email' })).not.toBeInTheDocument();
});
it('sends account activation email on clicking the resened email in account activation alert', async () => {
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(); });

View File

@@ -66,8 +66,8 @@ function CertificateStatusAlert({ intl, payload }) {
alertProps.body = (
<p>
<FormattedMessage
id="learning.outline.alert.cert.when"
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and certificates are
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,

View File

@@ -2,8 +2,8 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
certStatusEarnedNotAvailableHeader: {
id: 'cert.alert.earned.unavailable.header',
defaultMessage: 'Your grade and certificate will be ready soon!',
id: 'cert.alert.earned.unavailable.header.v2',
defaultMessage: 'Your grade and certificate status will be available soon.',
description: 'Header alerting the user that their certificate will be available soon.',
},
certStatusDownloadableHeader: {
@@ -13,7 +13,7 @@ const messages = defineMessages({
},
certStatusNotPassingHeader: {
id: 'cert.alert.notPassing.header',
defaultMessage: 'You are not eligible for a certificate',
defaultMessage: 'You are not yet eligible for a certificate',
},
certStatusNotPassingButton: {
id: 'cert.alert.notPassing.button',

View File

@@ -4,6 +4,22 @@ const messages = defineMessages({
allDates: {
id: 'learning.outline.dates.all',
defaultMessage: 'View all course dates',
description: 'Text anchor for link that redirects to dates or course timeline tab',
},
casualGoalButtonText: {
id: 'learning.outline.goalButton.casual.text',
defaultMessage: '1 day a week',
description: 'Text shown for casual goal button',
},
casualGoalButtonTitle: {
id: 'learning.outline.goalButton.screenReader.text',
defaultMessage: 'Casual',
description: 'A very short description of the least intense of three learning goals',
},
certAlt: {
id: 'learning.outline.certificateAlt',
defaultMessage: 'Example Certificate',
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
},
collapseAll: {
id: 'learning.outline.collapseAll',
@@ -23,6 +39,7 @@ const messages = defineMessages({
dates: {
id: 'learning.outline.dates',
defaultMessage: 'Important dates',
description: 'Headline for the (summary of dates) section of the outline page',
},
editGoal: {
id: 'learning.outline.editGoal',
@@ -39,6 +56,11 @@ const messages = defineMessages({
defaultMessage: 'Goal',
description: 'Label for the selected course goal',
},
goalReminderDetail: {
id: 'learning.outline.goalReminderDetail',
defaultMessage: 'If we notice youre not quite at your goal, well send you an email reminder.',
description: 'It describe to learner what is goal reminder service',
},
goalUnsure: {
id: 'learning.outline.goalUnsure',
defaultMessage: 'Not sure yet',
@@ -46,6 +68,7 @@ const messages = defineMessages({
handouts: {
id: 'learning.outline.handouts',
defaultMessage: 'Course Handouts',
description: 'Header for (Course Handouts) section in course outline',
},
incompleteAssignment: {
id: 'learning.outline.incompleteAssignment',
@@ -57,6 +80,16 @@ const messages = defineMessages({
defaultMessage: 'Incomplete section',
description: 'Text used to describe the gray checkmark icon in front of a section title',
},
intenseGoalButtonText: {
id: 'learning.outline.goalButton.intense.text',
defaultMessage: '5 days a week',
description: 'Text shown for intense goal button',
},
intenseGoalButtonTitle: {
id: 'learning.outline.goalButton.intense.title',
defaultMessage: 'Intense',
description: 'A very short description of the most intensive option of three learning goals, Casual, Regular and Intense',
},
learnMore: {
id: 'learning.outline.learnMore',
defaultMessage: 'Learn More',
@@ -66,34 +99,80 @@ const messages = defineMessages({
defaultMessage: 'Open',
description: 'A button to open the given section of the course outline',
},
proctoringInfoPanel: {
id: 'learning.proctoringPanel.header',
defaultMessage: 'This course contains proctored exams',
description: 'Used as a label to indicate that course has proctored exams',
},
regularGoalButtonText: {
id: 'learning.outline.goalButton.regular.text',
defaultMessage: '3 days a week',
description: 'Text shown for regular goal button',
},
regularGoalButtonTitle: {
id: 'learning.outline.goalButton.regular.title',
defaultMessage: 'Regular',
description: 'A very short description of the middle option of three learning goals, Casual, Regular and Intense',
},
resumeBlurb: {
id: 'learning.outline.resumeBlurb',
defaultMessage: 'Pick up where you left off',
description: 'Text describing to the learner that they can return to the last section they visited within the course.',
},
resume: {
id: 'learning.outline.resume',
defaultMessage: 'Resume course',
description: 'Anchor text for button that would resume course',
},
setGoal: {
id: 'learning.outline.setGoal',
defaultMessage: 'To start, set a course goal by selecting the option below that best describes your learning plan.',
description: 'In indicate to learner how to set or use the goal reminder service',
},
setGoalReminder: {
id: 'learning.outline.setGoalReminder',
defaultMessage: 'Set a goal reminder',
description: 'The text for the radio button which activate or deactivate the goal reminder service',
},
setLearningGoalButtonScreenReaderText: {
id: 'learning.outline.goalButton.casual.title',
defaultMessage: 'Set a learning goal style.',
description: 'screen reader text informing learner they can select an intensity of learning goal',
},
setWeeklyGoal: {
id: 'learning.outline.setWeeklyGoal',
defaultMessage: 'Set a weekly learning goal',
description: 'The headline for (goal reminder service) section in course outline',
},
setWeeklyGoalDetail: {
id: 'learning.outline.setWeeklyGoalDetail',
defaultMessage: 'Setting a goal motivates you to finish the course. You can always change it later.',
description: 'It indiacate the gaol or the purpose of the goal reminder service to learners',
},
start: {
id: 'learning.outline.start',
defaultMessage: 'Start Course',
defaultMessage: 'Start course',
description: 'The text for button which starts the course',
},
startBlurb: {
id: 'learning.outline.startBlurb',
defaultMessage: 'Begin your course today',
},
tools: {
id: 'learning.outline.tools',
defaultMessage: 'Course Tools',
description: 'Headline for the (course tools) section in course outline. course tool might include links to course bookmarks, financial assistance...etc',
},
upgradeButton: {
id: 'learning.outline.upgradeButton',
defaultMessage: 'Upgrade ({symbol}{price})',
description: 'Text for the button which redirects to the upgrading page',
},
upgradeTitle: {
id: 'learning.outline.upgradeTitle',
defaultMessage: 'Pursue a verified certificate',
},
certAlt: {
id: 'learning.outline.certificateAlt',
defaultMessage: 'Example Certificate',
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
description: 'Upgrade title',
},
welcomeMessage: {
id: 'learning.outline.welcomeMessage',
@@ -112,113 +191,135 @@ const messages = defineMessages({
defaultMessage: 'Welcome to',
description: 'This precedes the title of the course',
},
proctoringInfoPanel: {
id: 'learning.proctoringPanel.header',
defaultMessage: 'This course contains proctored exams',
},
notStartedProctoringStatus: {
id: 'learning.proctoringPanel.status.notStarted',
defaultMessage: 'Not Started',
description: 'It indcate that proctortrack onboarding exam hasnt started yet',
},
startedProctoringStatus: {
id: 'learning.proctoringPanel.status.started',
defaultMessage: 'Started',
description: 'Label to indicate the starting status of the proctortrack onboarding exam',
},
submittedProctoringStatus: {
id: 'learning.proctoringPanel.status.submitted',
defaultMessage: 'Submitted',
description: 'Label to indicate the submitted status of proctortrack onboarding exam',
},
verifiedProctoringStatus: {
id: 'learning.proctoringPanel.status.verified',
defaultMessage: 'Verified',
description: 'Label to indicate the verified status of the proctortrack onboarding exam',
},
rejectedProctoringStatus: {
id: 'learning.proctoringPanel.status.rejected',
defaultMessage: 'Rejected',
description: 'Label to indicate the rejection status of the proctortrack onboarding exam',
},
errorProctoringStatus: {
id: 'learning.proctoringPanel.status.error',
defaultMessage: 'Error',
description: 'Label to indicate that there is error in proctortrack onboarding exam',
},
otherCourseApprovedProctoringStatus: {
id: 'learning.proctoringPanel.status.otherCourseApproved',
defaultMessage: 'Approved in Another Course',
description: 'Label to indicate that the proctortrack onboarding exam is verified based on taking onboarding exam on another course',
},
expiringSoonProctoringStatus: {
id: 'learning.proctoringPanel.status.expiringSoon',
defaultMessage: 'Expiring Soon',
description: 'A label to indicate that proctortrack onboarding exam will expire soon',
},
proctoringCurrentStatus: {
id: 'learning.proctoringPanel.status',
defaultMessage: 'Current Onboarding Status:',
description: 'The text that precede the status label of proctortrack onboarding exam',
},
notStartedProctoringMessage: {
id: 'learning.proctoringPanel.message.notStarted',
defaultMessage: 'You have not started your onboarding exam.',
description: 'The text that explain the meaning of (not started) label of the proctortrack onboarding exam',
},
startedProctoringMessage: {
id: 'learning.proctoringPanel.message.started',
defaultMessage: 'You have started your onboarding exam.',
description: 'The text that explain the meaning of (started) label of the proctortrack onboarding exam',
},
submittedProctoringMessage: {
id: 'learning.proctoringPanel.message.submitted',
defaultMessage: 'You have submitted your onboarding exam.',
description: 'The text that explain the meaning of (submitted) label of the proctortrack onboarding exam',
},
verifiedProctoringMessage: {
id: 'learning.proctoringPanel.message.verified',
defaultMessage: 'Your onboarding exam has been approved in this course.',
description: 'The text that explain the meaning of (verified) label of the proctortrack onboarding exam',
},
rejectedProctoringMessage: {
id: 'learning.proctoringPanel.message.rejected',
defaultMessage: 'Your onboarding exam has been rejected. Please retry onboarding.',
description: 'The text that explain the meaning of (rejected) label of the proctortrack onboarding exam',
},
errorProctoringMessage: {
id: 'learning.proctoringPanel.message.error',
defaultMessage: 'An error has occurred during your onboarding exam. Please retry onboarding.',
description: 'The text that explain the meaning of (error) label of the proctortrack onboarding exam',
},
otherCourseApprovedProctoringMessage: {
id: 'learning.proctoringPanel.message.otherCourseApproved',
defaultMessage: 'Your onboarding exam has been approved in another course.',
description: 'The text that explain the meaning of (approved in another course) label of the proctortrack onboarding exam',
},
otherCourseApprovedProctoringDetail: {
id: 'learning.proctoringPanel.detail.otherCourseApproved',
defaultMessage: 'If your device has changed, we recommend that you complete this course\'s onboarding exam in order to ensure that your setup still meets the requirements for proctoring.',
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (approved in another course)',
},
expiringSoonProctoringMessage: {
id: 'learning.proctoringPanel.message.expiringSoon',
defaultMessage: 'Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expiring soon)',
},
proctoringPanelGeneralInfo: {
id: 'learning.proctoringPanel.generalInfo',
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ',
description: 'It indicate key and important fact to learner about the importance of taking proctortrack onboarding exam',
},
proctoringPanelGeneralInfoSubmitted: {
id: 'learning.proctoringPanel.generalInfoSubmitted',
defaultMessage: 'Your submitted profile is in review.',
description: 'The text that explain the meaning of (in review) label of the proctortrack onboarding exam',
},
proctoringPanelGeneralTime: {
id: 'learning.proctoringPanel.generalTime',
defaultMessage: 'Onboarding profile review can take 2+ business days.',
description: 'This text explain for how long the (in review) status of the proctortrack onboarding exam might remain',
},
proctoringOnboardingButton: {
id: 'learning.proctoringPanel.onboardingButton',
defaultMessage: 'Complete Onboarding',
description: 'Text shown on the button that starts the actual proctortrack onboarding exam when it is released',
},
proctoringOnboardingPracticeButton: {
id: 'learning.proctoringPanel.onboardingPracticeButton',
defaultMessage: 'View Onboarding Exam',
description: 'The text that appears on onboarding exam while its not released, so learners can take or view it as a practice',
},
proctoringOnboardingButtonNotOpen: {
id: 'learning.proctoringPanel.onboardingButtonNotOpen',
defaultMessage: 'Onboarding Opens: {releaseDate}',
description: 'It indicate when or from when the learner can take the proctortrack onboarding exam',
},
proctoringReviewRequirementsButton: {
id: 'learning.proctoringPanel.reviewRequirementsButton',
defaultMessage: 'Review instructions and system requirements',
description: 'Anchor text for button which redirect leaner to doc or a detailed page about proctortrack onboarding exam',
},
proctoringOnboardingButtonPastDue: {
id: 'learning.proctoringPanel.onboardingButtonPastDue',
defaultMessage: 'Onboarding Past Due',
description: 'Text that show when the deadline of proctortrack onboarding exam has passed, it appears on button that start the onboarding exam however for this case the button is disabled for obvious reason',
},
});

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -8,11 +9,13 @@ import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function CourseDates({
courseId,
intl,
/** [MM-P2P] Experiment */
mmp2p,
}) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
userTimezone,
} = useModel('courseHomeMeta', courseId);
@@ -29,34 +32,34 @@ function CourseDates({
return (
<section className="mb-4">
<h2 className="h4">{intl.formatMessage(messages.dates)}</h2>
<ol className="list-unstyled">
{courseDateBlocks.map((courseDateBlock) => (
<DateSummary
key={courseDateBlock.title + courseDateBlock.date}
dateBlock={courseDateBlock}
userTimezone={userTimezone}
/** [MM-P2P] Experiment */
mmp2p={mmp2p}
/>
))}
</ol>
<a className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
{intl.formatMessage(messages.allDates)}
</a>
<div id="courseHome-dates">
<h2 className="h4">{intl.formatMessage(messages.dates)}</h2>
<ol className="list-unstyled">
{courseDateBlocks.map((courseDateBlock) => (
<DateSummary
key={courseDateBlock.title + courseDateBlock.date}
dateBlock={courseDateBlock}
userTimezone={userTimezone}
/** [MM-P2P] Experiment */
mmp2p={mmp2p}
/>
))}
</ol>
<a className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
{intl.formatMessage(messages.allDates)}
</a>
</div>
</section>
);
}
CourseDates.propTypes = {
courseId: PropTypes.string,
intl: intlShape.isRequired,
/** [MM-P2P] Experiment */
mmp2p: PropTypes.shape({}),
};
CourseDates.defaultProps = {
courseId: null,
/** [MM-P2P] Experiment */
mmp2p: {},
};

View File

@@ -1,94 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, Card } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import { saveCourseGoal } from '../../data';
function CourseGoalCard({
courseId,
goalOptions,
intl,
title,
setGoalToDisplay,
setGoalToastHeader,
}) {
function selectGoalHandler(event) {
const selectedGoal = {
key: event.currentTarget.getAttribute('data-goal-key'),
text: event.currentTarget.getAttribute('data-goal-text'),
};
saveCourseGoal(courseId, selectedGoal.key).then((response) => {
const { data } = response;
const {
header,
} = data;
setGoalToDisplay(selectedGoal);
setGoalToastHeader(header);
});
}
return (
<Card className="mb-3" data-testid="course-goal-card">
<Card.Body>
<div className="row w-100 m-0 justify-content-between align-items-center">
<div className="col col-8 p-0">
<h2 className="h4 m-0">{intl.formatMessage(messages.welcomeTo)} {title}</h2>
</div>
<div className="col col-auto p-0">
<Button
variant="link"
className="p-0"
size="sm"
block
data-goal-key="unsure"
data-goal-text={`${intl.formatMessage(messages.goalUnsure)}`}
onClick={(event) => { selectGoalHandler(event); }}
>
{intl.formatMessage(messages.goalUnsure)}
</Button>
</div>
</div>
<Card.Text className="my-2 mx-1 text-dark-500">{intl.formatMessage(messages.setGoal)}</Card.Text>
<div className="row w-100 m-0">
{goalOptions.map((goal) => {
const [goalKey, goalText] = goal;
return (
(goalKey !== 'unsure') && (
<div key={`goal-${goalKey}`} className="col-auto flex-grow-1 mx-1 my-2 p-0">
<Button
variant="outline-primary"
block
data-goal-key={goalKey}
data-goal-text={goalText}
onClick={(event) => { selectGoalHandler(event); }}
>
{goalText}
</Button>
</div>
)
);
})}
</div>
</Card.Body>
</Card>
);
}
CourseGoalCard.propTypes = {
courseId: PropTypes.string.isRequired,
goalOptions: PropTypes.arrayOf(
PropTypes.arrayOf(PropTypes.string),
).isRequired,
intl: intlShape.isRequired,
title: PropTypes.string.isRequired,
setGoalToDisplay: PropTypes.func.isRequired,
setGoalToastHeader: PropTypes.func.isRequired,
};
export default injectIntl(CourseGoalCard);

View File

@@ -1,5 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -7,7 +7,10 @@ import LmsHtmlFragment from '../LmsHtmlFragment';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function CourseHandouts({ courseId, intl }) {
function CourseHandouts({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
handoutsHtml,
} = useModel('outline', courseId);
@@ -29,7 +32,6 @@ function CourseHandouts({ courseId, intl }) {
}
CourseHandouts.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};

View File

@@ -1,5 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
@@ -12,8 +12,12 @@ import { faNewspaper } from '@fortawesome/free-regular-svg-icons';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import LaunchCourseHomeTourButton from '../../../product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton';
function CourseTools({ courseId, intl }) {
function CourseTools({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const { org } = useModel('courseHomeMeta', courseId);
const {
courseTools,
@@ -69,18 +73,16 @@ function CourseTools({ courseId, intl }) {
</a>
</li>
))}
<li className="small" id="courseHome-launchTourLink">
<LaunchCourseHomeTourButton />
</li>
</ul>
</section>
);
}
CourseTools.propTypes = {
courseId: PropTypes.string,
intl: intlShape.isRequired,
};
CourseTools.defaultProps = {
courseId: null,
};
export default injectIntl(CourseTools);

View File

@@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
function FlagButton({
buttonIcon,
title,
text,
handleSelect,
isSelected,
}) {
return (
<button
type="button"
className={classnames('flag-button row w-100 align-content-between m-1.5 py-3.5',
isSelected ? 'flag-button-selected' : '')}
aria-checked={isSelected}
role="radio"
onClick={() => handleSelect()}
data-testid={`weekly-learning-goal-input-${title}`}
>
<div className="row w-100 m-0 justify-content-center pb-1">
{buttonIcon}
</div>
<div className={classnames('row w-100 m-0 justify-content-center small text-gray-700 pb-1', isSelected ? 'font-weight-bold' : '')}>
{title}
</div>
<div className={classnames('row w-100 m-0 justify-content-center micro text-gray-500', isSelected ? 'font-weight-bold' : '')}>
{text}
</div>
</button>
);
}
FlagButton.propTypes = {
buttonIcon: PropTypes.element.isRequired,
title: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
handleSelect: PropTypes.func.isRequired,
isSelected: PropTypes.bool.isRequired,
};
export default FlagButton;

View File

@@ -0,0 +1,37 @@
@import "~@edx/brand/paragon/variables";
@import "~@edx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
.flag-button {
background-color: $white;
border: 1px solid $light-400;
border-radius: .2rem;
box-shadow: 0 0 0 2px $light-400;
&:hover {
border: 1px solid $primary-300;
box-shadow: 0 0 0 2px $white;
}
}
.flag-button-selected {
border: 1px solid $primary-300;
box-shadow: 0 0 0 2px $primary-300;
pointer-events: none;
}
// @see https://heydonworks.com/article/the-flexbox-holy-albatross-reincarnated/
// use the container size for layout instead of device media query
.flag-button-container {
display: flex;
flex-wrap: wrap;
--margin: 1rem;
--modifier: calc(20rem - 100%);
margin: calc(var(--margin) * -1);
}
.flag-button-container > * {
flex-grow: 1;
flex-basis: calc(var(--modifier) * 999);
margin: var(--margin);
}

View File

@@ -0,0 +1,59 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
// These flag svgs are derivatives of the Flag icon from paragon
import { ReactComponent as FlagIntenseIcon } from './flag_black.svg';
import { ReactComponent as FlagCasualIcon } from './flag_outline.svg';
import { ReactComponent as FlagRegularIcon } from './flag_gray.svg';
import FlagButton from './FlagButton';
import messages from '../messages';
function LearningGoalButton({
level,
isSelected,
handleSelect,
intl,
}) {
const buttonDetails = {
casual: {
daysPerWeek: 1,
title: messages.casualGoalButtonTitle,
text: messages.casualGoalButtonText,
icon: <FlagCasualIcon />,
},
regular: {
daysPerWeek: 3,
title: messages.regularGoalButtonTitle,
text: messages.regularGoalButtonText,
icon: <FlagRegularIcon />,
},
intense: {
daysPerWeek: 5,
title: messages.intenseGoalButtonTitle,
text: messages.intenseGoalButtonText,
icon: <FlagIntenseIcon />,
},
};
const values = buttonDetails[level];
return (
<FlagButton
buttonIcon={values.icon}
title={intl.formatMessage(values.title)}
text={intl.formatMessage(values.text)}
handleSelect={() => handleSelect(values.daysPerWeek)}
isSelected={isSelected}
/>
);
}
LearningGoalButton.propTypes = {
level: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired,
handleSelect: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(LearningGoalButton);

View File

@@ -1,14 +1,24 @@
import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import camelCase from 'lodash.camelcase';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import messages from '../messages';
import { getProctoringInfoData } from '../../data/api';
import { fetchProctoringInfoResolved } from '../../data/slice';
import { useModel } from '../../../generic/model-store';
function ProctoringInfoPanel({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
username,
} = useModel('courseHomeMeta', courseId);
const dispatch = useDispatch();
function ProctoringInfoPanel({ courseId, username, intl }) {
const [link, setLink] = useState('');
const [onboardingPastDue, setOnboardingPastDue] = useState(false);
const [showInfoPanel, setShowInfoPanel] = useState(false);
@@ -95,7 +105,13 @@ function ProctoringInfoPanel({ courseId, username, intl }) {
setOnboardingPastDue(response.onboarding_past_due);
}
},
);
)
.catch(() => {
/* Do nothing. API throws 404 when class does not have proctoring */
})
.finally(() => {
dispatch(fetchProctoringInfoResolved());
});
}, []);
let onboardingExamButton = null;
@@ -183,13 +199,7 @@ function ProctoringInfoPanel({ courseId, username, intl }) {
}
ProctoringInfoPanel.propTypes = {
courseId: PropTypes.string.isRequired,
username: PropTypes.string,
intl: intlShape.isRequired,
};
ProctoringInfoPanel.defaultProps = {
username: null,
};
export default injectIntl(ProctoringInfoPanel);

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { Button, Card } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function StartOrResumeCourseCard({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
org,
} = useModel('courseHomeMeta', courseId);
const eventProperties = {
org_key: org,
courserun_key: courseId,
};
const {
resumeCourse: {
hasVisitedCourse,
url: resumeCourseUrl,
},
} = useModel('outline', courseId);
if (!resumeCourseUrl) {
return null;
}
const logResumeCourseClick = () => {
sendTrackingLogEvent('edx.course.home.resume_course.clicked', {
...eventProperties,
event_type: hasVisitedCourse ? 'resume' : 'start',
url: resumeCourseUrl,
});
};
return (
<Card className="mb-3 raised-card" data-testid="start-resume-card">
<Card.Header
title={hasVisitedCourse ? intl.formatMessage(messages.resumeBlurb) : intl.formatMessage(messages.startBlurb)}
actions={(
<Button
variant="brand"
block
href={resumeCourseUrl}
onClick={() => logResumeCourseClick()}
>
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
</Button>
)}
/>
{/* Footer is needed for internal vertical spacing to work out. If you can remove, be my guest */}
<Card.Footer><></></Card.Footer>
</Card>
);
}
StartOrResumeCourseCard.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(StartOrResumeCourseCard);

View File

@@ -1,85 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon';
import messages from '../messages';
import { saveCourseGoal } from '../../data';
function UpdateGoalSelector({
courseId,
goalOptions,
intl,
selectedGoal,
setGoalToDisplay,
setGoalToastHeader,
}) {
function selectGoalHandler(event) {
const key = event.currentTarget.id;
const text = event.currentTarget.innerText;
const newGoal = {
key,
text,
};
setGoalToDisplay(newGoal);
saveCourseGoal(courseId, key).then((response) => {
const { data } = response;
const {
header,
} = data;
setGoalToastHeader(header);
});
}
return (
<>
<section className="mb-4">
<div className="row w-100 m-0">
<div className="col-12 p-0">
<label className="h4 m-0" htmlFor="edit-goal-selector">
{intl.formatMessage(messages.goal)}
</label>
</div>
<div className="col-12 p-0">
<Dropdown className="py-2">
<Dropdown.Toggle variant="outline-primary" block id="edit-goal-selector" data-testid="edit-goal-selector">
{selectedGoal.text}
</Dropdown.Toggle>
<Dropdown.Menu>
{goalOptions.map(([goalKey, goalText]) => (
<Dropdown.Item
id={goalKey}
key={goalKey}
onClick={(event) => { selectGoalHandler(event); }}
role="button"
>
{goalText}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
</div>
</div>
</section>
</>
);
}
UpdateGoalSelector.propTypes = {
courseId: PropTypes.string.isRequired,
goalOptions: PropTypes.arrayOf(
PropTypes.arrayOf(PropTypes.string),
).isRequired,
intl: intlShape.isRequired,
selectedGoal: PropTypes.shape({
key: PropTypes.string,
text: PropTypes.string,
}).isRequired,
setGoalToDisplay: PropTypes.func.isRequired,
setGoalToastHeader: PropTypes.func.isRequired,
};
export default injectIntl(UpdateGoalSelector);

View File

@@ -0,0 +1,140 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Form, Card, Icon } from '@edx/paragon';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Email } from '@edx/paragon/icons';
import { useSelector } from 'react-redux';
import messages from '../messages';
import LearningGoalButton from './LearningGoalButton';
import { saveWeeklyLearningGoal } from '../../data';
import { useModel } from '../../../generic/model-store';
import './FlagButton.scss';
function WeeklyLearningGoalCard({
daysPerWeek,
subscribedToReminders,
intl,
}) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
isMasquerading,
org,
} = useModel('courseHomeMeta', courseId);
const { administrator } = getAuthenticatedUser();
const [daysPerWeekGoal, setDaysPerWeekGoal] = useState(daysPerWeek);
// eslint-disable-next-line react/prop-types
const [isGetReminderSelected, setGetReminderSelected] = useState(subscribedToReminders);
function handleSelect(days) {
// Set the subscription button if this is the first time selecting a goal
const selectReminders = daysPerWeekGoal === null ? true : isGetReminderSelected;
setGetReminderSelected(selectReminders);
setDaysPerWeekGoal(days);
if (!isMasquerading) { // don't save goal updates while masquerading
saveWeeklyLearningGoal(courseId, days, selectReminders);
sendTrackEvent('edx.ui.lms.goal.days-per-week.changed', {
org_key: org,
courserun_key: courseId,
is_staff: administrator,
num_days: days,
reminder_selected: selectReminders,
});
}
}
function handleSubscribeToReminders(event) {
const isGetReminderChecked = event.target.checked;
setGetReminderSelected(isGetReminderChecked);
if (!isMasquerading) { // don't save goal updates while masquerading
saveWeeklyLearningGoal(courseId, daysPerWeekGoal, isGetReminderChecked);
sendTrackEvent('edx.ui.lms.goal.reminder-selected.changed', {
org_key: org,
courserun_key: courseId,
is_staff: administrator,
num_days: daysPerWeekGoal,
reminder_selected: isGetReminderChecked,
});
}
}
return (
<Card
id="courseHome-weeklyLearningGoal"
className="row w-100 m-0 mb-3 raised-card"
data-testid="weekly-learning-goal-card"
>
<Card.Header
size="sm"
title={(<div id="set-weekly-goal-header">{intl.formatMessage(messages.setWeeklyGoal)}</div>)}
subtitle={intl.formatMessage(messages.setWeeklyGoalDetail)}
/>
<Card.Section className="text-gray-700 small">
<div
role="radiogroup"
aria-labelledby="set-weekly-goal-header"
className="flag-button-container m-0 p-0"
>
<LearningGoalButton
level="casual"
isSelected={daysPerWeekGoal === 1}
handleSelect={handleSelect}
/>
<LearningGoalButton
level="regular"
isSelected={daysPerWeekGoal === 3}
handleSelect={handleSelect}
/>
<LearningGoalButton
level="intense"
isSelected={daysPerWeekGoal === 5}
handleSelect={handleSelect}
/>
</div>
<div className="d-flex pt-3">
<Form.Switch
checked={isGetReminderSelected}
onChange={(event) => handleSubscribeToReminders(event)}
disabled={!daysPerWeekGoal}
>
<small>{intl.formatMessage(messages.setGoalReminder)}</small>
</Form.Switch>
</div>
</Card.Section>
{isGetReminderSelected && (
<Card.Section muted>
<div className="row w-100 m-0 small align-center">
<div className="d-flex align-items-center pr-1">
<Icon
className="text-primary-500"
src={Email}
/>
</div>
<div className="col">
{intl.formatMessage(messages.goalReminderDetail)}
</div>
</div>
</Card.Section>
)}
</Card>
);
}
WeeklyLearningGoalCard.propTypes = {
daysPerWeek: PropTypes.number,
subscribedToReminders: PropTypes.bool,
intl: intlShape.isRequired,
};
WeeklyLearningGoalCard.defaultProps = {
daysPerWeek: null,
subscribedToReminders: false,
};
export default injectIntl(WeeklyLearningGoalCard);

View File

@@ -37,6 +37,7 @@ function WelcomeMessage({ courseId, intl }) {
setDisplay(false);
dispatch(dismissWelcomeMessage(courseId));
}}
className="raised-card"
actions={messageCanBeShortened ? [
<Button
onClick={() => setShowShortMessage(!showShortMessage)}

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.4 6L14 4H5V21H7V14H12.6L13 16H20V6H14.4Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 173 B

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="15"
height="17"
viewBox="0 0 15 17"
fill="none"
version="1.1"
id="svg11"
xmlns="http://www.w3.org/2000/svg">
<path
d="M9.4 2L9 0H0V17H2V10H7.6L8 12H15V2H9.4ZM13 10H9.64L9.24 8H2V2H7.36L7.76 4H13V10Z"
fill="#002B2B"
id="path9" />
<path
style="fill:#808080;fill-rule:evenodd;stroke-width:0.0150977"
d="M 9.6594698,9.9871226 C 9.6577909,9.9829707 9.5654776,9.5311723 9.4543296,8.9831261 L 9.2522415,7.9866785 5.6376662,7.9790074 2.0230906,7.9713362 V 4.9970494 2.0227625 l 2.6636151,0.00771 2.6636151,0.00771 0.1968204,0.9888987 0.1968205,0.9888988 h 2.6200263 2.620026 v 2.9893428 2.9893428 h -1.660746 c -0.91341,0 -1.6621194,-0.0034 -1.6637982,-0.00755 z"
id="path302" />
</svg>

After

Width:  |  Height:  |  Size: 801 B

View File

@@ -0,0 +1,3 @@
<svg width="15" height="17" viewBox="0 0 15 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.4 2L9 0H0V17H2V10H7.6L8 12H15V2H9.4ZM13 10H9.64L9.24 8H2V2H7.36L7.76 4H13V10Z" fill="#002B2B"/>
</svg>

After

Width:  |  Height:  |  Size: 211 B

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { layoutGenerator } from 'react-break';
import { useSelector } from 'react-redux';
import { breakpoints, useWindowSize } from '@edx/paragon';
import CertificateStatus from './certificate-status/CertificateStatus';
import CourseCompletion from './course-completion/CourseCompletion';
@@ -23,13 +23,15 @@ function ProgressTab() {
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
const layout = layoutGenerator({
mobile: 0,
desktop: 992,
});
const windowWidth = useWindowSize().width;
if (windowWidth === undefined) {
// Bail because we don't want to load <CertificateStatus/> twice, emitting 'visited' events both times.
// This is a hacky solution, since the user can resize the screen and still get two visited events.
// But I'm leaving a larger refactor as an exercise to a future reader.
return null;
}
const OnMobile = layout.is('mobile');
const OnDesktop = layout.isAtLeast('desktop');
const wideScreen = windowWidth >= breakpoints.large.minWidth;
return (
<>
<ProgressHeader />
@@ -37,11 +39,9 @@ function ProgressTab() {
{/* Main body */}
<div className="col-12 col-md-8 p-0">
<CourseCompletion />
<OnMobile>
<CertificateStatus />
</OnMobile>
{!wideScreen && <CertificateStatus />}
<CourseGrade />
<div className={`grades my-4 p-4 rounded shadow-sm ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}>
<div className={`grades my-4 p-4 rounded raised-card ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}>
<GradeSummary />
<DetailedGrades />
</div>
@@ -49,9 +49,7 @@ function ProgressTab() {
{/* Side panel */}
<div className="col-12 col-md-4 p-0 px-md-4">
<OnDesktop>
<CertificateStatus />
</OnDesktop>
{wideScreen && <CertificateStatus />}
<RelatedLinks />
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { breakpoints } from '@edx/paragon';
import MockAdapter from 'axios-mock-adapter';
import {
@@ -13,6 +14,7 @@ import * as thunks from '../data/thunks';
import initializeStore from '../../store';
import ProgressTab from './ProgressTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage';
import messages from './grades/messages';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
@@ -20,18 +22,18 @@ jest.mock('@edx/frontend-platform/analytics');
describe('Progress Tab', () => {
let axiosMock;
const courseId = 'course-v1:edX+Test+run';
const store = initializeStore();
const defaultMetadata = Factory.build('courseHomeMetadata');
const defaultTabData = Factory.build('progressTabData');
const courseId = defaultMetadata.id;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`);
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
const store = initializeStore();
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
const defaultTabData = Factory.build('progressTabData');
function setMetadata(attributes, options) {
const courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
const courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
}
@@ -63,6 +65,7 @@ describe('Progress Tab', () => {
it('sends event on click of dates tab link', async () => {
await fetchAndRender();
sendTrackEvent.mockClear();
const datesTabLink = screen.getByRole('link', { name: 'Dates' });
fireEvent.click(datesTabLink);
@@ -78,6 +81,7 @@ describe('Progress Tab', () => {
it('sends event on click of outline tab link', async () => {
await fetchAndRender();
sendTrackEvent.mockClear();
const outlineTabLink = screen.getAllByRole('link', { name: 'Course Outline' });
fireEvent.click(outlineTabLink[1]); // outlineTabLink[0] corresponds to the link in the DetailedGrades component
@@ -129,6 +133,8 @@ describe('Progress Tab', () => {
});
await fetchAndRender();
expect(screen.queryByRole('button', { name: 'Grade range tooltip' })).not.toBeInTheDocument();
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('50%');
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('50%');
expect(screen.getByText('A weighted grade of 75% is required to pass in this course')).toBeInTheDocument();
});
@@ -162,6 +168,8 @@ describe('Progress Tab', () => {
});
await fetchAndRender();
expect(screen.getByRole('button', { name: 'Grade range tooltip' }));
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('0%');
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('0%');
expect(screen.getByText('A weighted grade of 80% is required to pass in this course')).toBeInTheDocument();
});
@@ -209,6 +217,8 @@ describe('Progress Tab', () => {
});
await fetchAndRender();
expect(screen.getByRole('button', { name: 'Grade range tooltip' }));
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('80%');
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('80%');
expect(await screen.findByText('Youre currently passing this course with a grade of B (80-90%)')).toBeInTheDocument();
});
@@ -285,7 +295,6 @@ describe('Progress Tab', () => {
});
it('sends events on click of upgrade button in locked content header (CourseGradeHeader)', async () => {
sendTrackEvent.mockClear();
setTabData({
completion_summary: {
complete_count: 1,
@@ -322,6 +331,7 @@ describe('Progress Tab', () => {
],
});
await fetchAndRender();
sendTrackEvent.mockClear();
expect(screen.getByText('locked feature')).toBeInTheDocument();
expect(screen.getByText('Unlock to view grades and work towards a certificate.')).toBeInTheDocument();
@@ -438,9 +448,8 @@ describe('Progress Tab', () => {
expect(screen.queryAllByTestId('blocked-icon')).toHaveLength(4);
});
it('renders correct current grade tooltip when showGrades is false', async () => {
// The learner has a 50% on the first assignment and a 100% on the second, making their grade a 75%
// The second assignment has showGrades set to false, so the grade reflected to the learner should be 50%.
it('does not render subsections for which showGrades is false', async () => {
// The second assignment has showGrades set to false, so it should not be shown.
setTabData({
section_scores: [
{
@@ -482,10 +491,31 @@ describe('Progress Tab', () => {
});
await fetchAndRender();
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('50%');
// Although the learner's true grade is passing, we should expect this to reflect the grade that's
// visible to them, which is non-passing
expect(screen.getByText('A weighted grade of 75% is required to pass in this course')).toBeInTheDocument();
expect(screen.getByText('First subsection')).toBeInTheDocument();
expect(screen.queryByText('Second subsection')).not.toBeInTheDocument();
});
it('renders correct title when credit information is available', async () => {
setTabData({
credit_course_requirements: {
eligibility_status: 'eligible',
requirements: [
{
namespace: 'proctored_exam',
name: 'i4x://edX/DemoX/proctoring-block/final_uuid',
display_name: 'Proctored Mid Term Exam',
criteria: {},
reason: {},
status: 'satisfied',
status_date: '2015-06-26 11:07:42',
order: 1,
},
],
},
});
await fetchAndRender();
expect(screen.getByText('Grades & Credit')).toBeInTheDocument();
});
});
@@ -634,9 +664,7 @@ describe('Progress Tab', () => {
expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument();
});
it('renders correct total weighted grade when showGrades is false', async () => {
// The learner has a 50% on the first assignment and a 100% on the second, making their grade a 75%
// The second assignment has showGrades set to false, so the grade reflected to the learner should be 50%.
it('renders override notice', async () => {
setTabData({
section_scores: [
{
@@ -647,12 +675,16 @@ describe('Progress Tab', () => {
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
has_graded_assignment: true,
learner_has_access: true,
num_points_earned: 1,
num_points_possible: 2,
percent_graded: 1.0,
problem_scores: [{
earned: 1,
possible: 2,
}],
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
@@ -660,15 +692,24 @@ describe('Progress Tab', () => {
display_name: 'Second section',
subsections: [
{
assignment_type: 'Homework',
assignment_type: 'Exam',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@98765',
display_name: 'Second subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 1,
num_points_earned: 0,
num_points_possible: 1,
override: {
system: 'PROCTORING',
reason: 'Suspicious activity',
},
percent_graded: 1.0,
problem_scores: [{
earned: 0,
possible: 1,
}],
show_correctness: 'always',
show_grades: false,
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
},
],
@@ -677,7 +718,14 @@ describe('Progress Tab', () => {
});
await fetchAndRender();
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('50%');
const problemScoreDrawerToggle = screen.getByRole('button', { name: 'Toggle individual problem scores for Second subsection' });
expect(problemScoreDrawerToggle).toBeInTheDocument();
// Open the problem score drawer
fireEvent.click(problemScoreDrawerToggle);
expect(screen.getByText(messages.sectionGradeOverridden.defaultMessage)).toBeInTheDocument();
});
});
@@ -691,8 +739,8 @@ describe('Progress Tab', () => {
});
it('sends event on click of subsection link', async () => {
sendTrackEvent.mockClear();
await fetchAndRender();
sendTrackEvent.mockClear();
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
const subsectionLink = screen.getByRole('link', { name: 'First subsection' });
@@ -708,8 +756,8 @@ describe('Progress Tab', () => {
});
it('sends event on click of course outline link', async () => {
sendTrackEvent.mockClear();
await fetchAndRender();
sendTrackEvent.mockClear();
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
const outlineLink = screen.getAllByRole('link', { name: 'Course Outline' })[0];
@@ -749,22 +797,7 @@ describe('Progress Tab', () => {
describe('Certificate Status', () => {
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => {
const matches = !!(query === 'screen and (min-width: 992px)');
return {
matches,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
};
}),
});
global.innerWidth = breakpoints.large.minWidth;
});
describe('enrolled user', () => {
@@ -1189,6 +1222,54 @@ describe('Progress Tab', () => {
});
});
describe('Credit Information', () => {
it('renders credit information when provided', async () => {
setTabData({
credit_course_requirements: {
eligibility_status: 'eligible',
requirements: [
{
namespace: 'proctored_exam',
name: 'i4x://edX/DemoX/proctoring-block/final_uuid',
display_name: 'Proctored Mid Term Exam',
criteria: {},
reason: {},
status: null,
status_date: '2015-06-26 11:07:42',
order: 1,
},
{
namespace: 'grade',
name: 'i4x://edX/DemoX/proctoring-block/final_uuid',
display_name: 'Minimum Passing Grade',
criteria: { min_grade: 0.8 },
reason: { final_grade: 0.95 },
status: 'satisfied',
status_date: '2015-06-26 11:07:44',
order: 2,
},
],
},
});
await fetchAndRender();
expect(screen.getByText('Grades & Credit')).toBeInTheDocument();
expect(screen.getByText('Requirements for course credit')).toBeInTheDocument();
expect(screen.getByText('You have met the requirements for credit in this course.', { exact: false })).toBeInTheDocument();
expect(screen.getByText('Proctored Mid Term Exam:')).toBeInTheDocument();
// 80% comes from the criteria.minGrade being 0.8
expect(screen.getByText('Minimum grade for credit (80%):')).toBeInTheDocument();
// Completed because the grade requirement has been satisfied
expect(screen.getByText('Completed')).toBeInTheDocument();
});
it('does not render credit information when it is not provided', async () => {
await fetchAndRender();
expect(screen.queryByText('Grades & Credit')).not.toBeInTheDocument();
expect(screen.queryByText('Requirements for course credit.')).not.toBeInTheDocument();
});
});
describe('Access expiration masquerade banner', () => {
it('renders banner when masquerading as a user', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });

View File

@@ -115,6 +115,7 @@ function CertificateStatus({ intl }) {
<FormattedMessage
id="progress.certificateStatus.unverifiedBody"
defaultMessage="In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}."
description="Its shown when learner are not verified thus it recommends going over the verification process"
values={{ idVerificationSupportLink }}
/>
);
@@ -133,6 +134,7 @@ function CertificateStatus({ intl }) {
Showcase your accomplishment on LinkedIn or your resumé today.
You can download your certificate now and access it any time from your
{dashboardLink} and {profileLink}."
description="Recommending an action for learner when course certificate is available"
values={{ dashboardLink, profileLink }}
/>
);
@@ -155,8 +157,9 @@ function CertificateStatus({ intl }) {
body = (
<FormattedMessage
id="courseCelebration.certificateBody.notAvailable.endDate"
defaultMessage="This course ends on {endDate}. Final grades and certificates are
defaultMessage="This course ends on {endDate}. Final grades and any earned certificates are
scheduled to be available after {certAvailabilityDate}."
description="This shown for leaner when they are eligible for certifcate but it't not available yet, it could because leaners just finished the course quickly!"
values={{ endDate, certAvailabilityDate }}
/>
);
@@ -221,14 +224,12 @@ function CertificateStatus({ intl }) {
return (
<section data-testid="certificate-status-component" className="text-dark-700 mb-4">
<Card className="bg-light-200 shadow-sm border-0">
<Card.Body>
<Card.Title>
<h3>{header}</h3>
</Card.Title>
<Card.Text className="small text-gray-700">
{body}
</Card.Text>
<Card className="bg-light-200 raised-card">
<Card.Header title={header} />
<Card.Section className="small text-gray-700">
{body}
</Card.Section>
<Card.Footer>
{buttonText && (buttonLocation || buttonAction) && (
<Button
variant="outline-brand"
@@ -242,7 +243,7 @@ function CertificateStatus({ intl }) {
{buttonText}
</Button>
)}
</Card.Body>
</Card.Footer>
</Card>
</section>
);

View File

@@ -4,86 +4,107 @@ const messages = defineMessages({
notPassingHeader: {
id: 'progress.certificateStatus.notPassingHeader',
defaultMessage: 'Certificate status',
description: 'Header text when learner certifcate status is not passing',
},
notPassingBody: {
id: 'progress.certificateStatus.notPassingBody',
defaultMessage: 'In order to qualify for a certificate, you must have a passing grade.',
description: 'Body text when learner certifcate status is not passing',
},
inProgressHeader: {
id: 'progress.certificateStatus.inProgressHeader',
defaultMessage: 'More content is coming soon!',
description: 'Header text when learner certifcate is in progress',
},
inProgressBody: {
id: 'progress.certificateStatus.inProgressBody',
defaultMessage: 'It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.',
description: 'Body text when learner certifcate is in progress',
},
requestableHeader: {
id: 'progress.certificateStatus.requestableHeader',
defaultMessage: 'Certificate status',
description: 'Header text when learner certifcate status is requestable',
},
requestableBody: {
id: 'progress.certificateStatus.requestableBody',
defaultMessage: 'Congratulations, you qualified for a certificate! In order to access your certificate, request it below.',
description: 'Body text when learner certifcate status is requestable',
},
requestableButton: {
id: 'progress.certificateStatus.requestableButton',
defaultMessage: 'Request certificate',
description: 'Button text when learner certifcate status is requestable',
},
unverifiedHeader: {
id: 'progress.certificateStatus.unverifiedHeader',
defaultMessage: 'Certificate status',
description: 'Header text when learner certifcate status is unverified',
},
unverifiedButton: {
id: 'progress.certificateStatus.unverifiedButton',
defaultMessage: 'Verify ID',
description: 'Button text when learner certifcate status is unverified',
},
unverifiedPendingBody: {
id: 'progress.certificateStatus.courseCelebration.verificationPending',
defaultMessage: 'Your ID verification is pending and your certificate will be available once approved.',
description: 'Body text when learner certifcate status is unverified pending',
},
downloadableHeader: {
id: 'progress.certificateStatus.downloadableHeader',
defaultMessage: 'Your certificate is available!',
description: 'Header text when the certifcate is available',
},
downloadableBody: {
id: 'progress.certificateStatus.downloadableBody',
defaultMessage: 'Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.',
description: 'Recommending an action for learner when course certificate is available',
},
downloadableButton: {
id: 'progress.certificateStatus.downloadableButton',
defaultMessage: 'Download my certificate',
description: 'Button text when learner certifcate status is downloadable',
},
viewableButton: {
id: 'progress.certificateStatus.viewableButton',
defaultMessage: 'View my certificate',
description: 'Button text which view or links to the certifcate',
},
notAvailableHeader: {
id: 'progress.certificateStatus.notAvailableHeader',
defaultMessage: 'Certificate status',
description: 'Header text when the certifcate is not available',
},
upgradeHeader: {
id: 'progress.certificateStatus.upgradeHeader',
defaultMessage: 'Earn a certificate',
description: 'Header text when the learner needs to upgrade to earn a certifcate ',
},
upgradeBody: {
id: 'progress.certificateStatus.upgradeBody',
defaultMessage: 'You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.',
description: 'Body text when the learner needs to upgrade to earn a certifcate ',
},
upgradeButton: {
id: 'progress.certificateStatus.upgradeButton',
defaultMessage: 'Upgrade now',
description: 'Button text which leaner needs to upgrade to get the certifcate',
},
unverifiedHomeHeader: {
id: 'progress.certificateStatus.unverifiedHomeHeader',
defaultMessage: 'Verify your identity to earn a certificate!',
id: 'progress.certificateStatus.unverifiedHomeHeader.v2',
defaultMessage: 'Verify your identity to qualify for a certificate.',
description: 'Header text when the learner needs to do verification to earn a certifcate ',
},
unverifiedHomeButton: {
id: 'progress.certificateStatus.unverifiedHomeButton',
defaultMessage: 'Verify my ID',
description: 'Button text which leaner needs to do verification to earn a certifcate',
},
unverifiedHomeBody: {
id: 'progress.certificateStatus.unverifiedHomeBody',
defaultMessage: 'In order to generate a certificate for this course, you must complete the ID verification process.',
description: 'Body text when the learner needs to do verification to earn a certifcate',
},
});

View File

@@ -6,7 +6,7 @@ import messages from './messages';
function CourseCompletion({ intl }) {
return (
<section className="text-dark-700 mb-4 rounded shadow-sm p-4">
<section className="text-dark-700 mb-4 rounded raised-card p-4">
<div className="row w-100 m-0">
<div className="col-12 col-sm-6 col-md-7 p-0">
<h2>{intl.formatMessage(messages.courseCompletion)}</h2>

View File

@@ -4,38 +4,47 @@ const messages = defineMessages({
donutLabel: {
id: 'progress.completion.donut.label',
defaultMessage: 'completed',
description: 'Label text for progress donut chart',
},
completionBody: {
id: 'progress.completion.body',
defaultMessage: 'This represents how much of the course content you have completed. Note that some content may not yet be released.',
description: 'It explains the meaning of progress donut chart',
},
completeContentTooltip: {
id: 'progress.completion.tooltip.locked',
defaultMessage: 'Content that you have completed.',
description: 'It expalains the meaning of content that is completed',
},
courseCompletion: {
id: 'progress.completion.header',
defaultMessage: 'Course completion',
description: 'Header text for (completion donut chart) section of the progress tab',
},
incompleteContentTooltip: {
id: 'progress.completion.tooltip',
defaultMessage: 'Content that you have access to and have not completed.',
description: 'It explain the meaning for content is completed',
},
lockedContentTooltip: {
id: 'progress.completion.tooltip.complete',
defaultMessage: 'Content that is locked and available only to those who upgrade.',
description: 'It expalains the meaning of content that is locked',
},
percentComplete: {
id: 'progress.completion.donut.percentComplete',
defaultMessage: 'You have completed {percent}% of content in this course.',
description: 'It summarize the progress in the course (100% - %incomplete)',
},
percentIncomplete: {
id: 'progress.completion.donut.percentIncomplete',
defaultMessage: 'You have not completed {percent}% of content in this course that you have access to.',
description: 'It summarize the progress in the course (100% - %complete)',
},
percentLocked: {
id: 'progress.completion.donut.percentLocked',
defaultMessage: '{percent}% of content in this course is locked and available only for those who upgrade.',
description: 'It indicate the relative size of content that is locked in the course (100% - %open_content)',
},
});

View File

@@ -0,0 +1,115 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { CheckCircle, WarningFilled, WatchFilled } from '@edx/paragon/icons';
import { Hyperlink, Icon } from '@edx/paragon';
import { useModel } from '../../../generic/model-store';
import { DashboardLink } from '../../../shared/links';
import messages from './messages';
function CreditInformation({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
creditCourseRequirements,
} = useModel('progress', courseId);
if (!creditCourseRequirements) { return null; }
let eligibilityStatus;
let requirementStatus;
const requirements = [];
const dashboardLink = <DashboardLink />;
const creditLink = (
<Hyperlink
variant="muted"
isInline
destination={getConfig().CREDIT_HELP_LINK_URL}
>{intl.formatMessage(messages.courseCredit)}
</Hyperlink>
);
switch (creditCourseRequirements.eligibilityStatus) {
case 'not_eligible':
eligibilityStatus = (
<FormattedMessage
id="progress.creditInformation.creditNotEligible"
defaultMessage="You are no longer eligible for credit in this course. Learn more about {creditLink}."
description="Message to learner who are not eligible for course credit, it can because the a requirement deadline have passed"
values={{ creditLink }}
/>
);
break;
case 'eligible':
eligibilityStatus = (
<FormattedMessage
id="progress.creditInformation.creditEligible"
defaultMessage="
You have met the requirements for credit in this course. Go to your
{dashboardLink} to purchase course credit. Or learn more about {creditLink}."
description="After the credit requirements are met, leaners can then do the last step which purchasing the credit. Note that is only doable for leaners after they met all the requirements"
values={{ dashboardLink, creditLink }}
/>
);
break;
case 'partial_eligible':
eligibilityStatus = (
<FormattedMessage
id="progress.creditInformation.creditPartialEligible"
defaultMessage="You have not yet met the requirements for credit. Learn more about {creditLink}."
description="This means that one or more requirements is not satisfied yet"
values={{ creditLink }}
/>
);
break;
default:
break;
}
creditCourseRequirements.requirements.forEach(requirement => {
switch (requirement.status) {
case 'submitted':
requirementStatus = (<>{intl.formatMessage(messages.verificationSubmitted)} <Icon src={CheckCircle} className="text-success-500 d-inline-flex align-bottom" /></>);
break;
case 'failed':
case 'declined':
requirementStatus = (<>{intl.formatMessage(messages.verificationFailed)} <Icon src={WarningFilled} className="d-inline-flex align-bottom" /></>);
break;
case 'satisfied':
requirementStatus = (<>{intl.formatMessage(messages.completed)} <Icon src={CheckCircle} className="text-success-500 d-inline-flex align-bottom" /></>);
break;
default:
requirementStatus = (<>{intl.formatMessage(messages.upcoming)} <Icon src={WatchFilled} className="text-gray-500 d-inline-flex align-bottom" /></>);
}
requirements.push((
<div className="row w-100 m-0 small" key={`requirement-${requirement.order}`}>
<p className="font-weight-bold">
{requirement.namespace === 'grade'
? `${intl.formatMessage(messages.minimumGrade, { minGrade: Number(requirement.criteria.minGrade) * 100 })}:`
: `${requirement.displayName}:`}
</p>
<div className="ml-1">
{requirementStatus}
</div>
</div>
));
});
return (
<>
<h3 className="h4 col-12 p-0">{intl.formatMessage(messages.requirementsHeader)}</h3>
<p className="small">{eligibilityStatus}</p>
{requirements}
</>
);
}
CreditInformation.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CreditInformation);

View File

@@ -0,0 +1,40 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
completed: {
id: 'progress.creditInformation.completed',
defaultMessage: 'Completed',
description: 'Label text if a requirement for (course credit) is satisfied',
},
courseCredit: {
id: 'progress.creditInformation.courseCredit',
defaultMessage: 'course credit',
description: 'Anchor text for link that redirects (course credit) help page',
},
minimumGrade: {
id: 'progress.creditInformation.minimumGrade',
defaultMessage: 'Minimum grade for credit ({minGrade}%)',
},
requirementsHeader: {
id: 'progress.creditInformation.requirementsHeader',
defaultMessage: 'Requirements for course credit',
description: 'Header for the requirements section in course credit',
},
upcoming: {
id: 'progress.creditInformation.upcoming',
defaultMessage: 'Upcoming',
description: 'It indicate that the a (credit requirement) status is not known yet',
},
verificationFailed: {
id: 'progress.creditInformation.verificationFailed',
defaultMessage: 'Verification failed',
description: 'It indicate that the learner submitted a requirement but is either failed or declined',
},
verificationSubmitted: {
id: 'progress.creditInformation.verificationSubmitted',
defaultMessage: 'Verification submitted',
description: 'It indicate that the learner submitted a requirement but is not graded or reviewed yet',
},
});
export default messages;

View File

@@ -7,6 +7,7 @@ import { useModel } from '../../../../generic/model-store';
import CourseGradeFooter from './CourseGradeFooter';
import CourseGradeHeader from './CourseGradeHeader';
import GradeBar from './GradeBar';
import CreditInformation from '../../credit-information/CreditInformation';
import messages from '../messages';
@@ -16,6 +17,7 @@ function CourseGrade({ intl }) {
} = useSelector(state => state.courseHome);
const {
creditCourseRequirements,
gradesFeatureIsFullyLocked,
gradesFeatureIsPartiallyLocked,
gradingPolicy: {
@@ -28,18 +30,24 @@ function CourseGrade({ intl }) {
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
return (
<section className="text-dark-700 my-4 rounded shadow-sm">
<section className="text-dark-700 my-4 rounded raised-card">
{(gradesFeatureIsFullyLocked || gradesFeatureIsPartiallyLocked) && <CourseGradeHeader />}
<div className={applyLockedOverlay} aria-hidden={gradesFeatureIsFullyLocked}>
<div className="row w-100 m-0 p-4">
<div className="col-12 col-sm-6 p-0 pr-sm-2">
<h2>{intl.formatMessage(messages.grades)}</h2>
<div className="col-12 col-sm-6 p-0 pr-sm-5.5">
<h2>{creditCourseRequirements
? intl.formatMessage(messages.gradesAndCredit)
: intl.formatMessage(messages.grades)}
</h2>
<p className="small">
{intl.formatMessage(messages.courseGradeBody)}
</p>
</div>
<GradeBar passingGrade={passingGrade} />
</div>
<div className="row w-100 m-0 px-4">
<CreditInformation />
</div>
<CourseGradeFooter passingGrade={passingGrade} />
</div>
</section>

View File

@@ -2,11 +2,9 @@ import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { layoutGenerator } from 'react-break';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { CheckCircle, WarningFilled } from '@edx/paragon/icons';
import { Icon } from '@edx/paragon';
import { breakpoints, Icon, useWindowSize } from '@edx/paragon';
import { useModel } from '../../../../generic/model-store';
import GradeRangeTooltip from './GradeRangeTooltip';
@@ -27,13 +25,7 @@ function CourseGradeFooter({ intl, passingGrade }) {
},
} = useModel('progress', courseId);
const layout = layoutGenerator({
mobile: 0,
tablet: 768,
});
const OnMobile = layout.is('mobile');
const OnAtLeastTablet = layout.isAtLeast('tablet');
const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth;
const hasLetterGrades = Object.keys(gradeRange).length > 1; // A pass/fail course will only have one key
let footerText = intl.formatMessage(messages.courseGradeFooterNonPassing, { passingGrade });
@@ -66,7 +58,7 @@ function CourseGradeFooter({ intl, passingGrade }) {
{icon}
</div>
<div className="col-11 pl-2 px-0">
<OnMobile>
{!wideScreen && (
<span className="h5 align-bottom">
{footerText}
{hasLetterGrades && (
@@ -76,8 +68,8 @@ function CourseGradeFooter({ intl, passingGrade }) {
</span>
)}
</span>
</OnMobile>
<OnAtLeastTablet>
)}
{wideScreen && (
<span className="h4 m-0 align-bottom">
{footerText}
{hasLetterGrades && (
@@ -87,7 +79,7 @@ function CourseGradeFooter({ intl, passingGrade }) {
</span>
)}
</span>
</OnAtLeastTablet>
)}
</div>
</div>
);

View File

@@ -2,7 +2,9 @@ import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { OverlayTrigger, Popover } from '@edx/paragon';
import { useModel } from '../../../../generic/model-store';
@@ -17,11 +19,19 @@ function CurrentGradeTooltip({ intl, tooltipClassName }) {
const {
courseGrade: {
isPassing,
visiblePercent,
percent,
},
} = useModel('progress', courseId);
const currentGrade = Number((visiblePercent * 100).toFixed(0));
const currentGrade = Number((percent * 100).toFixed(0));
let currentGradeDirection = currentGrade < 50 ? '' : '-';
const isLocaleRtl = isRtl(getLocale());
if (isLocaleRtl) {
currentGradeDirection = currentGrade < 50 ? '-' : '';
}
return (
<>
@@ -37,16 +47,16 @@ function CurrentGradeTooltip({ intl, tooltipClassName }) {
)}
>
<g>
<circle cx={`${Math.min(...[currentGrade, 100])}%`} cy="50%" r="8.5" fill="transparent" />
<rect className="grade-bar__divider" x={`${Math.min(...[currentGrade, 100])}%`} style={{ transform: 'translateY(2.61em)' }} />
<circle cx={`${Math.min(...[isLocaleRtl ? 100 - currentGrade : currentGrade, 100])}%`} cy="50%" r="8.5" fill="transparent" />
<rect className="grade-bar__divider" x={`${Math.min(...[isLocaleRtl ? 100 - currentGrade : currentGrade, 100])}%`} style={{ transform: 'translateY(2.61em)' }} />
</g>
</OverlayTrigger>
<text
className="x-small"
textAnchor={currentGrade < 50 ? 'start' : 'end'}
x={`${Math.min(...[currentGrade, 100])}%`}
x={`${Math.min(...[isLocaleRtl ? 100 - currentGrade : currentGrade, 100])}%`}
y="20px"
style={{ transform: `translateX(${currentGrade < 50 ? '' : '-'}3.4em)` }}
style={{ transform: `translateX(${currentGradeDirection}3.4em)` }}
>
{intl.formatMessage(messages.currentGradeLabel)}
</text>

View File

@@ -17,17 +17,17 @@ function GradeBar({ intl, passingGrade }) {
const {
courseGrade: {
isPassing,
visiblePercent,
percent,
},
gradesFeatureIsFullyLocked,
} = useModel('progress', courseId);
const currentGrade = Number((visiblePercent * 100).toFixed(0));
const currentGrade = Number((percent * 100).toFixed(0));
const lockedTooltipClassName = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
return (
<div className="col-12 col-sm-6 align-self-center">
<div className="col-12 col-sm-6 align-self-center p-0">
<div className="sr-only">{intl.formatMessage(messages.courseGradeBarAltText, { currentGrade, passingGrade })}</div>
<svg width="100%" height="100px" className="grade-bar" aria-hidden="true">
<g style={{ transform: 'translateY(2.61em)' }}>

View File

@@ -1,12 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { OverlayTrigger, Popover } from '@edx/paragon';
import messages from '../messages';
function PassingGradeTooltip({ intl, passingGrade, tooltipClassName }) {
const isLocaleRtl = isRtl(getLocale());
let passingGradeDirection = passingGrade < 50 ? '' : '-';
if (isLocaleRtl) {
passingGradeDirection = passingGrade < 50 ? '-' : '';
}
return (
<>
<OverlayTrigger
@@ -21,17 +31,17 @@ function PassingGradeTooltip({ intl, passingGrade, tooltipClassName }) {
)}
>
<g>
<circle cx={`${passingGrade}%`} cy="50%" r="8.5" fill="transparent" />
<circle className="grade-bar--passing" cx={`${passingGrade}%`} cy="50%" r="4.5" />
<circle cx={`${isLocaleRtl ? 100 - passingGrade : passingGrade}%`} cy="50%" r="8.5" fill="transparent" />
<circle className="grade-bar--passing" cx={`${isLocaleRtl ? 100 - passingGrade : passingGrade}%`} cy="50%" r="4.5" />
</g>
</OverlayTrigger>
<text
className="x-small"
textAnchor={passingGrade < 50 ? 'start' : 'end'}
x={`${passingGrade}%`}
x={`${isLocaleRtl ? 100 - passingGrade : passingGrade}%`}
y="90px"
style={{ transform: `translateX(${passingGrade < 50 ? '' : '-'}3.4em)` }}
style={{ transform: `translateX(${passingGradeDirection}3.4em)` }}
>
{intl.formatMessage(messages.passingGradeLabel)}
</text>

View File

@@ -39,7 +39,8 @@ function DetailedGrades({ intl }) {
const outlineLink = (
<Hyperlink
className="muted-link inline-link"
variant="muted"
isInline
destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`}
onClick={logOutlineLinkClick}
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
@@ -67,6 +68,7 @@ function DetailedGrades({ intl }) {
<FormattedMessage
id="progress.ungradedAlert"
defaultMessage="For progress on ungraded aspects of the course, view your {outlineLink}."
description="Text that precede link that redirect to course outline page"
values={{ outlineLink }}
/>
</p>

View File

@@ -6,7 +6,9 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Collapsible, Icon, Row } from '@edx/paragon';
import { ArrowDropDown, ArrowDropUp, Blocked } from '@edx/paragon/icons';
import {
ArrowDropDown, ArrowDropUp, Blocked, Info,
} from '@edx/paragon/icons';
import messages from '../messages';
import { useModel } from '../../../../generic/model-store';
@@ -79,7 +81,21 @@ function SubsectionTitleCell({ intl, subsection }) {
</span>
</Row>
<Collapsible.Body className="d-flex w-100">
<ProblemScoreDrawer problemScores={problemScores} subsection={subsection} />
<div className="col">
{ subsection.override && (
<div className="row w-100 m-0 x-small ml-4 pt-2 pl-1 text-gray-700 flex-nowrap">
<div>
<Icon
src={Info}
className="x-small mr-1 text-primary-500"
style={{ height: '1.3em', width: '1.3em' }}
/>
</div>
<div>{intl.formatMessage(messages.sectionGradeOverridden)}</div>
</div>
)}
<ProblemScoreDrawer problemScores={problemScores} subsection={subsection} />
</div>
</Collapsible.Body>
</Collapsible.Advanced>
);
@@ -87,7 +103,20 @@ function SubsectionTitleCell({ intl, subsection }) {
SubsectionTitleCell.propTypes = {
intl: intlShape.isRequired,
subsection: PropTypes.shape.isRequired,
subsection: PropTypes.shape({
blockKey: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
learnerHasAccess: PropTypes.bool.isRequired,
override: PropTypes.shape({
system: PropTypes.string,
reason: PropTypes.string,
}),
problemScores: PropTypes.arrayOf(PropTypes.shape({
earned: PropTypes.number.isRequired,
possible: PropTypes.number.isRequired,
})).isRequired,
url: PropTypes.string,
}).isRequired,
};
export default injectIntl(SubsectionTitleCell);

View File

@@ -64,7 +64,7 @@ function GradeSummaryTable({ intl, setAllOfSomeAssignmentTypeIsLocked }) {
footnoteMarker = footnotes.length;
}
const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignment.type) ? 'greyed-out' : '';
const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignment.type);
return {
type: {

View File

@@ -15,12 +15,12 @@ function GradeSummaryTableFooter({ intl }) {
const {
courseGrade: {
isPassing,
visiblePercent,
percent,
},
} = useModel('progress', courseId);
const bgColor = isPassing ? 'bg-success-100' : 'bg-warning-100';
const totalGrade = (visiblePercent * 100).toFixed(0);
const totalGrade = (percent * 100).toFixed(0);
return (
<DataTable.TableFooter className={`border-top border-primary ${bgColor}`}>

View File

@@ -4,149 +4,196 @@ const messages = defineMessages({
assignmentType: {
id: 'progress.assignmentType',
defaultMessage: 'Assignment type',
description: 'Header for column that indicate type of the assignment in grade summary table',
},
backToContent: {
id: 'progress.footnotes.backToContent',
defaultMessage: 'Back to content',
description: 'Text for button that redirects to contnet',
},
courseGradeBody: {
id: 'progress.courseGrade.body',
defaultMessage: 'This represents your weighted grade against the grade needed to pass this course.',
description: 'This text is shown to explain the meaning of the (grade bar) chart',
},
courseGradeBarAltText: {
id: 'progress.courseGrade.gradeBar.altText',
defaultMessage: 'Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.',
description: 'Alt text for the grade chart bar',
},
courseGradeFooterGenericPassing: {
id: 'progress.courseGrade.footer.generic.passing',
defaultMessage: 'Youre currently passing this course',
description: 'This shown when learner weighted grade is greater or equal course passing grade',
},
courseGradeFooterNonPassing: {
id: 'progress.courseGrade.footer.nonPassing',
defaultMessage: 'A weighted grade of {passingGrade}% is required to pass in this course',
description: 'This shown when learner weighted grade is less than course passing grade',
},
courseGradeFooterPassingWithGrade: {
id: 'progress.courseGrade.footer.passing',
defaultMessage: 'Youre currently passing this course with a grade of {letterGrade} ({minGrade}-{maxGrade}%)',
description: 'This shown when learner weighted grade is greater or equal course passing grade amd course is using letter grade',
},
courseGradePreviewHeaderLocked: {
id: 'progress.courseGrade.preview.headerLocked',
defaultMessage: 'locked feature',
description: 'This when (progress page) feature is locked, sometimes learner needs to upgrade to get insight about their progress',
},
courseGradePreviewHeaderLimited: {
id: 'progress.courseGrade.preview.headerLimited',
defaultMessage: 'limited feature',
description: 'This when (progress page) feature is partially locked, it means leaners can see their progress but not get to a certificate',
},
courseGradePreviewHeaderAriaHidden: {
id: 'progress.courseGrade.preview.header.ariaHidden',
defaultMessage: 'Preview of a ',
description: 'This text precedes either (locked feature) or (limited feature)',
},
courseGradePreviewUnlockCertificateBody: {
id: 'progress.courseGrade.preview.body.unlockCertificate',
defaultMessage: 'Unlock to view grades and work towards a certificate.',
description: 'Recommending an action for learner when they need to upgrade to view progress and get a certificate',
},
courseGradePartialPreviewUnlockCertificateBody: {
id: 'progress.courseGrade.partialpreview.body.unlockCertificate',
defaultMessage: 'Unlock to work towards a certificate.',
description: 'Recommending an action for learner when they need to upgrade to get a certificate',
},
courseGradePreviewUpgradeDeadlinePassedBody: {
id: 'progress.courseGrade.preview.body.upgradeDeadlinePassed',
defaultMessage: 'The deadline to upgrade in this course has passed.',
description: 'Shown when learner no longer can upgrade',
},
courseGradePreviewUpgradeButton: {
id: 'progress.courseGrade.preview.button.upgrade',
defaultMessage: 'Upgrade now',
description: 'Text for button that redirects to the upgrade page',
},
courseGradeRangeTooltip: {
id: 'progress.courseGrade.gradeRange.tooltip',
defaultMessage: 'Grade ranges for this course:',
description: 'This shown when course is using (letter grade) to explain e.g. range for A, B, and C...etc',
},
courseOutline: {
id: 'progress.courseOutline',
defaultMessage: 'Course Outline',
description: 'Anchor text for link that redirects to (course outline) tab',
},
currentGradeLabel: {
id: 'progress.courseGrade.label.currentGrade',
defaultMessage: 'Your current grade',
description: 'Text label current leaner grade on (grade bar) chart',
},
detailedGrades: {
id: 'progress.detailedGrades',
defaultMessage: 'Detailed grades',
description: 'Headline for the (detailed grade) section in the progress tab',
},
detailedGradesEmpty: {
id: 'progress.detailedGrades.emptyTable',
defaultMessage: 'You currently have no graded problem scores.',
description: 'It indicate that there are no problem or assignments to be scored',
},
footnotesTitle: {
id: 'progress.footnotes.title',
defaultMessage: 'Grade summary footnotes',
description: 'Title for grade summary footnotes, if exists',
},
grade: {
id: 'progress.gradeSummary.grade',
defaultMessage: 'Grade',
description: 'Headline for (grade column) in grade summary table',
},
grades: {
id: 'progress.courseGrade.grades',
defaultMessage: 'Grades',
description: 'Headline for grades section in progress tab',
},
gradesAndCredit: {
id: 'progress.courseGrade.gradesAndCredit',
defaultMessage: 'Grades & Credit',
description: 'Headline for (grades and credit) section in progress tab',
},
gradeRangeTooltipAlt: {
id: 'progress.courseGrade.gradeRange.Tooltip',
defaultMessage: 'Grade range tooltip',
description: 'Alt text for icon which that triggers (tip box) for grade range',
},
gradeSummary: {
id: 'progress.gradeSummary',
defaultMessage: 'Grade summary',
description: 'Headline for the (grade summary) section in (grades) section in progress tab',
},
gradeSummaryLimitedAccessExplanation: {
id: 'progress.gradeSummary.limitedAccessExplanation',
defaultMessage: 'You have limited access to graded assignments as part of the audit track in this course.',
description: 'Text shown when learner has limited access to grade feature',
},
gradeSummaryTooltipAlt: {
id: 'progress.gradeSummary.tooltip.alt',
defaultMessage: 'Grade summary tooltip',
description: 'Alt text for icon which that triggers (tip box) for grade summary',
},
gradeSummaryTooltipBody: {
id: 'progress.gradeSummary.tooltip.body',
defaultMessage: "Your course assignment's weight is determined by your instructor. "
+ 'By multiplying your grade by the weight for that assignment type, your weighted grade is calculated. '
+ "Your weighted grade is what's used to determine if you pass the course.",
},
passingGradeLabel: {
id: 'progress.courseGrade.label.passingGrade',
defaultMessage: 'Passing grade',
},
problemScoreLabel: {
id: 'progress.detailedGrades.problemScore.label',
defaultMessage: 'Problem Scores:',
},
problemScoreToggleAltText: {
id: 'progress.detailedGrades.problemScore.toggleButton',
defaultMessage: 'Toggle individual problem scores for {subsectionTitle}',
},
score: {
id: 'progress.score',
defaultMessage: 'Score',
},
weight: {
id: 'progress.weight',
defaultMessage: 'Weight',
},
weightedGrade: {
id: 'progress.weightedGrade',
defaultMessage: 'Weighted grade',
},
weightedGradeSummary: {
id: 'progress.weightedGradeSummary',
defaultMessage: 'Your current weighted grade summary',
description: 'The content of (tip box) for the grade summary section',
},
noAccessToAssignmentType: {
id: 'progress.noAcessToAssignmentType',
defaultMessage: 'You do not have access to assignments of type {assignmentType}',
description: 'Its alt text for locked icon which is shown if assignment type in (grade summary table) is locked',
},
noAccessToSubsection: {
id: 'progress.noAcessToSubsection',
defaultMessage: 'You do not have access to subsection {displayName}',
description: 'Text shown when learner have limited access to grades feature',
},
passingGradeLabel: {
id: 'progress.courseGrade.label.passingGrade',
defaultMessage: 'Passing grade',
description: 'Label for mark on the (grade bar) chart which indicate the poisition of passing grade on the bar',
},
problemScoreLabel: {
id: 'progress.detailedGrades.problemScore.label',
defaultMessage: 'Problem Scores:',
description: 'Label text which precedes detailed view of all scores per assignment',
},
problemScoreToggleAltText: {
id: 'progress.detailedGrades.problemScore.toggleButton',
defaultMessage: 'Toggle individual problem scores for {subsectionTitle}',
description: 'Alt text for button which switches detailed view per module',
},
sectionGradeOverridden: {
id: 'progress.detailedGrades.overridden',
defaultMessage: 'Section grade has been overridden.',
description: 'This indicate that the graded score has been changed, it can happen if leaner initial assessment was not fair, might be for other reasons as well',
},
score: {
id: 'progress.score',
defaultMessage: 'Score',
description: 'It indicate how many points the learner have socred scored in particular assignment, or exam',
},
weight: {
id: 'progress.weight',
defaultMessage: 'Weight',
description: 'It indicate the weight of particular assignment on overall course grade, it is demeterined by course author',
},
weightedGrade: {
id: 'progress.weightedGrade',
defaultMessage: 'Weighted grade',
description: 'Weighed grade is calculated by (weight %) * (grade score) ',
},
weightedGradeSummary: {
id: 'progress.weightedGradeSummary',
defaultMessage: 'Your current weighted grade summary',
description: 'It the text precede the sum of weighted grades of all the assignment',
},
});
export default messages;

View File

@@ -4,6 +4,7 @@ const messages = defineMessages({
progressHeader: {
id: 'progress.header',
defaultMessage: 'Your progress',
description: 'Headline or title for the progress tab',
},
progressHeaderForTargetUser: {
id: 'progress.header.targetUser',
@@ -13,6 +14,7 @@ const messages = defineMessages({
studioLink: {
id: 'progress.link.studio',
defaultMessage: 'View grading in Studio',
description: 'Text shown for button that redirects to the studio if the user is a staff memember',
},
});

View File

@@ -4,22 +4,27 @@ const messages = defineMessages({
datesCardDescription: {
id: 'progress.relatedLinks.datesCard.description',
defaultMessage: 'A schedule view of your course due dates and upcoming assignments.',
description: 'It explain the content of the dates tab',
},
datesCardLink: {
id: 'progress.relatedLinks.datesCard.link',
defaultMessage: 'Dates',
description: 'Anchor text for link that redirects to dates tab',
},
outlineCardDescription: {
id: 'progress.relatedLinks.outlineCard.description',
defaultMessage: 'A birds-eye view of your course content.',
description: 'It explain the content of the course outline tab',
},
outlineCardLink: {
id: 'progress.relatedLinks.outlineCard.link',
defaultMessage: 'Course Outline',
description: 'Anchor text for link that redirects to course outline tab',
},
relatedLinks: {
id: 'progress.relatedLinks',
defaultMessage: 'Related links',
description: 'Headline for (related links) section in progress tab',
},
});

View File

@@ -5,6 +5,7 @@ const messages = defineMessages({
id: 'datesBanner.suggestedSchedule',
defaultMessage: 'Weve built a suggested schedule to help you stay on track. But dont worry—its flexible so you'
+ ' can learn at your own pace.',
description: 'Messaging that explain the gaol and the effect fo the suggested schedule',
},
upgradeToCompleteHeader: {
id: 'datesBanner.upgradeToCompleteGradedBanner.header',
@@ -15,6 +16,7 @@ const messages = defineMessages({
id: 'datesBanner.upgradeToCompleteGradedBanner.body',
defaultMessage: 'You are auditing this course, which means that you are unable to participate in graded'
+ ' assignments. To complete graded assignments as part of this course, you can upgrade today.',
description: 'It explain the effect of upgrading the course',
},
upgradeToCompleteButton: {
id: 'datesBanner.upgradeToCompleteGradedBanner.button',
@@ -25,6 +27,7 @@ const messages = defineMessages({
id: 'datesBanner.upgradeToResetBanner.body',
defaultMessage: '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.',
description: 'Text that explain the consequences of resetting dates when learner needs to upgrade to do so',
},
upgradeToShiftButton: {
id: 'datesBanner.upgradeToResetBanner.button',
@@ -35,11 +38,13 @@ const messages = defineMessages({
missedDeadlines: {
id: 'datesBanner.resetDatesBanner.header',
defaultMessage: 'It looks like you missed some important deadlines based on our suggested schedule.',
description: 'Text shown when leaner missed assignment due date',
},
shiftDatesBody: {
id: 'datesBanner.resetDatesBanner.body',
defaultMessage: 'To keep yourself on track, you can update this schedule and shift the past due assignments into'
+ ' the future. Dont worry—you wont lose any of the progress youve made when you shift your due dates.',
description: 'Text that explain the consequences of resetting dates',
},
shiftDatesButton: {
id: 'datesBanner.resetDatesBanner.button',

View File

@@ -10,7 +10,7 @@ function CourseTabsNavigation({
activeTabSlug, className, tabs, intl,
}) {
return (
<div className={classNames('course-tabs-navigation', className)}>
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
<div className="container-xl">
<Tabs
className="nav-underline-tabs"

View File

@@ -23,12 +23,10 @@ describe('Course Tabs Navigation', () => {
};
render(<CourseTabsNavigation {...mockData} />);
expect(screen.getByRole('link', { name: tabs[0].title }))
.toHaveAttribute('href', tabs[0].url)
.toHaveClass('active');
expect(screen.getByRole('link', { name: tabs[0].title })).toHaveAttribute('href', tabs[0].url);
expect(screen.getByRole('link', { name: tabs[0].title })).toHaveClass('active');
expect(screen.getByRole('link', { name: tabs[1].title }))
.toHaveAttribute('href', tabs[1].url)
.not.toHaveClass('active');
expect(screen.getByRole('link', { name: tabs[1].title })).toHaveAttribute('href', tabs[1].url);
expect(screen.getByRole('link', { name: tabs[1].title })).not.toHaveClass('active');
});
});

View File

@@ -1,2 +1,2 @@
export { default as Header } from './Header';
/* eslint-disable import/prefer-default-export */
export { default as CourseTabsNavigation } from './CourseTabsNavigation';

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
courseMaterial: {
id: 'learn.navigation.course.tabs.label',
defaultMessage: 'Course Material',
description: 'The accessible label for course tabs navigation',
},
});
export default messages;

View File

@@ -10,6 +10,7 @@ import {
fetchCourse,
fetchSequence,
getResumeBlock,
getSequenceForUnitDeprecated,
saveSequencePosition,
} from './data';
import { TabPage } from '../tab-page';
@@ -17,6 +18,7 @@ import { TabPage } from '../tab-page';
import Course from './course';
import { handleNextSectionCelebration } from './course/celebration';
// Look at where this is called in componentDidUpdate for more info about its usage
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
if (courseStatus === 'loaded' && !sequenceId) {
// Note that getResumeBlock is just an API call, not a redux thunk.
@@ -31,12 +33,14 @@ const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSe
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) {
history.replace(`/course/${courseId}/${unitId}`);
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) {
// If the section is non-empty, redirect to its first sequence.
@@ -49,14 +53,35 @@ const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequence
}
});
const checkUnitToSequenceUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, unit) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && unit) {
// If the sequence failed to load as a sequence, but it *did* load as a unit, then
// insert the unit's parent sequenceId into the URL.
history.replace(`/course/${courseId}/${unit.sequenceId}/${unit.id}`);
// Look at where this is called in componentDidUpdate for more info about its usage
const checkUnitToSequenceUnitRedirect = memoize((
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId,
) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && !section && !routeUnitId) {
if (sequenceMightBeUnit) {
// If the sequence failed to load as a sequence, but it is marked as a possible unit, then we need to look up the
// correct parent sequence for it, and redirect there.
const unitId = sequenceId; // just for clarity during the rest of this method
getSequenceForUnitDeprecated(courseId, unitId).then(
parentId => {
if (parentId) {
history.replace(`/course/${courseId}/${parentId}/${unitId}`);
} else {
history.replace(`/course/${courseId}`);
}
},
() => { // error case
history.replace(`/course/${courseId}`);
},
);
} else {
// Invalid sequence that isn't a unit either. Redirect up to main course.
history.replace(`/course/${courseId}`);
}
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
if (sequenceStatus === 'loaded' && sequence.id && !unitId) {
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
@@ -67,6 +92,33 @@ const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, s
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
if (sequenceStatus !== 'loaded' || !sequence.id) {
return;
}
const hasUnits = sequence.unitIds?.length > 0;
if (unitId === 'first') {
if (hasUnits) {
const firstUnitId = sequence.unitIds[0];
history.replace(`/course/${courseId}/${sequence.id}/${firstUnitId}`);
} else {
// No units... go to general sequence page
history.replace(`/course/${courseId}/${sequence.id}`);
}
} else if (unitId === 'last') {
if (hasUnits) {
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
history.replace(`/course/${courseId}/${sequence.id}/${lastUnitId}`);
} else {
// No units... go to general sequence page
history.replace(`/course/${courseId}/${sequence.id}`);
}
}
});
class CoursewareContainer extends Component {
checkSaveSequencePosition = memoize((unitId) => {
const {
@@ -111,9 +163,9 @@ class CoursewareContainer extends Component {
sequenceId,
courseStatus,
sequenceStatus,
sequenceMightBeUnit,
sequence,
firstSequenceId,
unitViaSequenceId,
sectionViaSequenceId,
match: {
params: {
@@ -128,12 +180,24 @@ class CoursewareContainer extends Component {
this.checkFetchCourse(routeCourseId);
this.checkFetchSequence(routeSequenceId);
// Check if we should save our sequence position. Only do this when the route unit ID changes.
this.checkSaveSequencePosition(routeUnitId);
// Coerce the route ids into null here because they can be undefined, but the redux ids would be null instead.
if (courseId !== (routeCourseId || null) || sequenceId !== (routeSequenceId || null)) {
// The non-route ids are pulled from redux state - they are changed at the same time as the status variables.
// But the route ids are pulled directly from the route. So if the route changes, and we start a fetch above,
// there's a race condition where the route ids are for one course, but the status and the other ids are for a
// different course. Since all the logic below depends on the status variables and the route unit id, we'll wait
// until the ids match and thus the redux states got updated. So just bail for now.
return;
}
// All courseware URLs should normalize to the format /course/:courseId/:sequenceId/:unitId
// via the series of redirection rules below.
// See docs/decisions/0008-liberal-courseware-path-handling.md for more context.
// (It would be ideal to move this logic into the thunks layer and perform
// all URL-changing checks at once. This should be done once the MFE is moved
// to the new Outlines API. See TNL-8182.)
// all URL-changing checks at once. See TNL-8182.)
// Check resume redirect:
// /course/:courseId -> /course/:courseId/:sequenceId/:unitId
@@ -161,16 +225,22 @@ class CoursewareContainer extends Component {
// Check unit to sequence-unit redirect:
// /course/:courseId/:unitId -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID of the parent sequence of :unitId.
checkUnitToSequenceUnitRedirect(courseStatus, courseId, sequenceStatus, unitViaSequenceId);
checkUnitToSequenceUnitRedirect(
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, sectionViaSequenceId, routeUnitId,
);
// Check to sequence to sequence-unit redirect:
// Check sequence to sequence-unit redirect:
// /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID the most-recently-active unit in the sequence, OR
// the ID of the first unit the sequence if none is active.
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
// Check if we should save our sequence position. Only do this when the route unit ID changes.
this.checkSaveSequencePosition(routeUnitId);
// Check sequence-unit marker to sequence-unit redirect:
// /course/:courseId/:sequenceId/first -> /course/:courseId/:sequenceId/:unitId
// /course/:courseId/:sequenceId/last -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID the first or last unit in the sequence.
// "Sequence unit marker" is an invented term used only in this component.
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
}
handleUnitNavigationClick = (nextUnitId) => {
@@ -197,18 +267,11 @@ class CoursewareContainer extends Component {
} = this.props;
if (nextSequence !== null) {
let nextUnitId = null;
if (nextSequence.unitIds.length > 0) {
[nextUnitId] = nextSequence.unitIds;
history.push(`/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
} else {
// Some sequences have no units. This will show a blank page with prev/next buttons.
history.push(`/course/${courseId}/${nextSequence.id}`);
}
history.push(`/course/${courseId}/${nextSequence.id}/first`);
const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
if (celebrateFirstSection && sequence.sectionId !== nextSequence.sectionId) {
handleNextSectionCelebration(sequenceId, nextSequence.id, nextUnitId);
handleNextSectionCelebration(sequenceId, nextSequence.id);
}
}
}
@@ -216,13 +279,7 @@ class CoursewareContainer extends Component {
handlePreviousSequenceClick = () => {
const { previousSequence, courseId } = this.props;
if (previousSequence !== null) {
if (previousSequence.unitIds.length > 0) {
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
history.push(`/course/${courseId}/${previousSequence.id}/${previousUnitId}`);
} else {
// Some sequences have no units. This will show a blank page with prev/next buttons.
history.push(`/course/${courseId}/${previousSequence.id}`);
}
history.push(`/course/${courseId}/${previousSequence.id}/last`);
}
}
@@ -259,18 +316,10 @@ class CoursewareContainer extends Component {
}
}
const unitShape = PropTypes.shape({
id: PropTypes.string.isRequired,
sequenceId: PropTypes.string.isRequired,
});
const sequenceShape = PropTypes.shape({
id: PropTypes.string.isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string),
sectionId: PropTypes.string.isRequired,
isTimeLimited: PropTypes.bool,
isProctored: PropTypes.bool,
legacyWebUrl: PropTypes.string,
});
const sectionShape = PropTypes.shape({
@@ -297,9 +346,9 @@ CoursewareContainer.propTypes = {
firstSequenceId: PropTypes.string,
courseStatus: PropTypes.oneOf(['loaded', 'loading', 'failed', 'denied']).isRequired,
sequenceStatus: PropTypes.oneOf(['loaded', 'loading', 'failed']).isRequired,
sequenceMightBeUnit: PropTypes.bool.isRequired,
nextSequence: sequenceShape,
previousSequence: sequenceShape,
unitViaSequenceId: unitShape,
sectionViaSequenceId: sectionShape,
course: courseShape,
sequence: sequenceShape,
@@ -315,7 +364,6 @@ CoursewareContainer.defaultProps = {
firstSequenceId: null,
nextSequence: null,
previousSequence: null,
unitViaSequenceId: null,
sectionViaSequenceId: null,
course: null,
sequence: null,
@@ -398,18 +446,13 @@ const sectionViaSequenceIdSelector = createSelector(
(sectionsById, sequenceId) => (sectionsById[sequenceId] ? sectionsById[sequenceId] : null),
);
const unitViaSequenceIdSelector = createSelector(
(state) => state.models.units || {},
(state) => state.courseware.sequenceId,
(unitsById, sequenceId) => (unitsById[sequenceId] ? unitsById[sequenceId] : null),
);
const mapStateToProps = (state) => {
const {
courseId,
sequenceId,
courseStatus,
sequenceStatus,
sequenceMightBeUnit,
} = state.courseware;
return {
@@ -417,13 +460,13 @@ const mapStateToProps = (state) => {
sequenceId,
courseStatus,
sequenceStatus,
sequenceMightBeUnit,
course: currentCourseSelector(state),
sequence: currentSequenceSelector(state),
previousSequence: previousSequenceSelector(state),
nextSequence: nextSequenceSelector(state),
firstSequenceId: firstSequenceIdSelector(state),
sectionViaSequenceId: sectionViaSequenceIdSelector(state),
unitViaSequenceId: unitViaSequenceIdSelector(state),
};
};

View File

@@ -17,6 +17,7 @@ import CoursewareContainer from './CoursewareContainer';
import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
import initializeStore from '../store';
import { appendBrowserTimezoneToUrl } from '../utils';
import { buildOutlineFromBlocks } from './data/__factories__/learningSequencesOutline.factory';
// NOTE: Because the unit creates an iframe, we choose to mock it out as its rendering isn't
// pertinent to this test. Instead, we render a simple div that displays the properties we expect
@@ -47,6 +48,7 @@ describe('CoursewareContainer', () => {
// By default, `setUpMockRequests()` will configure the mock LMS API to return use this data.
// Certain test cases override these in order to test with special blocks/metadata.
const defaultCourseMetadata = Factory.build('courseMetadata');
const defaultCourseHomeMetadata = Factory.build('courseHomeMetadata');
const defaultCourseId = defaultCourseMetadata.id;
const defaultUnitBlocks = [
Factory.build(
@@ -70,7 +72,7 @@ describe('CoursewareContainer', () => {
sequenceBlocks: [defaultSequenceBlock],
} = buildSimpleCourseBlocks(
defaultCourseId,
defaultCourseMetadata.name,
defaultCourseHomeMetadata.title,
{ unitBlocks: defaultUnitBlocks },
);
@@ -100,7 +102,9 @@ describe('CoursewareContainer', () => {
function setUpMockRequests(options = {}) {
// If we weren't given course blocks or metadata, use the defaults.
const courseBlocks = options.courseBlocks || defaultCourseBlocks;
const courseOutline = buildOutlineFromBlocks(courseBlocks);
const courseMetadata = options.courseMetadata || defaultCourseMetadata;
const courseHomeMetadata = options.courseHomeMetadata || defaultCourseHomeMetadata;
const courseId = courseMetadata.id;
// If we weren't given a list of sequence metadatas for URL mocking,
// then construct it ourselves by looking at courseBlocks.
@@ -118,21 +122,34 @@ describe('CoursewareContainer', () => {
))
);
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, courseOutline);
const courseMetadataUrl = appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
const courseHomeMetadataUrl = appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`);
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
sequenceMetadatas.forEach(sequenceMetadata => {
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceMetadata.item_id}`;
axiosMock.onGet(sequenceMetadataUrl).reply(200, sequenceMetadata);
const proctoredExamApiUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${courseId}/content_id/${sequenceMetadata.item_id}?is_learning_mfe=true`;
axiosMock.onGet(proctoredExamApiUrl).reply(200, { exam: {}, active_attempt: {} });
});
// Set up handlers for noticing when units are in the sequence spot
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
Object.values(courseBlocks.blocks)
.filter(block => block.type === 'vertical')
.forEach(unitBlock => {
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${unitBlock.id}`;
axiosMock.onGet(sequenceMetadataUrl).reply(422, {});
});
const discussionConfigUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/*`);
axiosMock.onGet(discussionConfigUrl).reply(200, { provider: 'legacy' });
}
async function loadContainer() {
@@ -156,15 +173,16 @@ describe('CoursewareContainer', () => {
describe('when receiving successful course data', () => {
const courseMetadata = defaultCourseMetadata;
const courseHomeMetadata = defaultCourseHomeMetadata;
const courseId = defaultCourseId;
function assertLoadedHeader(container) {
const courseHeader = container.querySelector('.course-header');
const courseHeader = container.querySelector('.learning-header');
// Ensure the course number and org appear - this proves we loaded course metadata properly.
expect(courseHeader).toHaveTextContent(courseMetadata.number);
expect(courseHeader).toHaveTextContent(courseMetadata.org);
expect(courseHeader).toHaveTextContent(courseHomeMetadata.number);
expect(courseHeader).toHaveTextContent(courseHomeMetadata.org);
// Ensure the course title is showing up in the header. This means we loaded course blocks properly.
expect(courseHeader.querySelector('.course-title')).toHaveTextContent(courseMetadata.name);
expect(courseHeader.querySelector('.course-title')).toHaveTextContent(courseHomeMetadata.title);
}
function assertSequenceNavigation(container, expectedUnitCount = 3) {
@@ -188,7 +206,7 @@ describe('CoursewareContainer', () => {
const sequenceBlock = defaultSequenceBlock;
const unitBlocks = defaultUnitBlocks;
it('should use the resume block repsonse to pick a unit if it contains one', async () => {
it('should use the resume block response to pick a unit if it contains one', async () => {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {
sectionId: sequenceBlock.id,
unitId: unitBlocks[1].id,
@@ -233,7 +251,7 @@ describe('CoursewareContainer', () => {
const {
courseBlocks, unitTree, sequenceTree, sectionTree,
} = buildBinaryCourseBlocks(
courseId, courseMetadata.name,
courseId, courseHomeMetadata.title,
);
function setUrl(urlSequenceId, urlUnitId = null) {
@@ -259,6 +277,12 @@ describe('CoursewareContainer', () => {
assertSequenceNavigation(container, 2);
assertLocation(container, sequenceTree[1][1].id, urlUnit.id);
});
it('should ignore invalid unit IDs and redirect to the course root', async () => {
setUrl(sectionTree[1].id, 'foobar');
await loadContainer();
expect(global.location.href).toEqual(`http://localhost/course/${courseId}`);
});
});
describe('when the URL does not contain a unit ID', () => {
@@ -295,13 +319,20 @@ describe('CoursewareContainer', () => {
await loadContainer();
expect(global.location.href).toEqual(`http://localhost/course/${courseId}`);
});
});
});
it('should ignore the section and unit IDs and instead to the course root', async () => {
// Specific unit ID used here shouldn't matter; is ignored due to empty section.
setUrl(sectionTree[1].id, unitTree[0][0][0]);
await loadContainer();
expect(global.location.href).toEqual(`http://localhost/course/${courseId}`);
});
describe('when the URL contains a unit marker', () => {
it('should redirect /first to the first unit', async () => {
history.push(`/course/${courseId}/${defaultSequenceBlock.id}/first`);
await loadContainer();
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${defaultSequenceBlock.id}/${defaultUnitBlocks[0].id}`);
});
it('should redirect /last to the last unit', async () => {
history.push(`/course/${courseId}/${defaultSequenceBlock.id}/last`);
await loadContainer();
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${defaultSequenceBlock.id}/${defaultUnitBlocks[2].id}`);
});
});
@@ -413,17 +444,19 @@ describe('CoursewareContainer', () => {
describe('when receiving a course_access error_code', () => {
function setUpWithDeniedStatus(errorCode) {
const courseMetadata = Factory.build('courseMetadata', {
const courseMetadata = Factory.build('courseMetadata');
const courseHomeMetadata = Factory.build('courseHomeMetadata', {
course_access: {
has_access: false,
error_code: errorCode,
additional_context_user_message: 'uhoh oh no', // only used by audit_expired
},
});
const courseId = courseMetadata.id;
const { courseBlocks, sequenceBlocks, unitBlocks } = buildSimpleCourseBlocks(courseId, courseMetadata.name);
setUpMockRequests({ courseBlocks, courseMetadata });
setUpMockRequests({ courseBlocks, courseMetadata, courseHomeMetadata });
history.push(`/course/${courseId}/${sequenceBlocks[0].id}/${unitBlocks[0].id}`);
return { courseMetadata, unitBlocks };
}
@@ -442,13 +475,6 @@ describe('CoursewareContainer', () => {
expect(global.location.href).toEqual(`http://localhost/redirect/survey/${courseMetadata.id}`);
});
it('should go to legacy courseware for a microfrontend_disabled error code', async () => {
const { courseMetadata, unitBlocks } = setUpWithDeniedStatus('microfrontend_disabled');
await loadContainer();
expect(global.location.href).toEqual(`http://localhost/redirect/courseware/${courseMetadata.id}/unit/${unitBlocks[0].id}`);
});
it('should go to course home for an authentication_required error code', async () => {
const { courseMetadata } = setUpWithDeniedStatus('authentication_required');
await loadContainer();
@@ -478,4 +504,21 @@ describe('CoursewareContainer', () => {
expect(global.location.href).toEqual(`http://localhost/redirect/dashboard?notlive=${startDate}`);
});
});
describe('redirects when canLoadCourseware is false', () => {
it('should go to legacy courseware for disabled frontend', async () => {
const courseMetadata = Factory.build('courseMetadata');
const courseHomeMetadata = Factory.build('courseHomeMetadata', {
can_load_courseware: false,
});
const courseId = courseMetadata.id;
const { courseBlocks, sequenceBlocks, unitBlocks } = buildSimpleCourseBlocks(courseId, courseMetadata.name);
setUpMockRequests({ courseBlocks, courseMetadata, courseHomeMetadata });
history.push(`/course/${courseId}/${sequenceBlocks[0].id}/${unitBlocks[0].id}`);
await loadContainer();
expect(global.location.href).toEqual(`http://localhost/redirect/courseware/${courseMetadata.id}/unit/${unitBlocks[0].id}`);
});
});
});

View File

@@ -1,14 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { useModel } from '../generic/model-store';
export default function CourseRedirect({ match }) {
const {
courseId,
unitId,
} = match.params;
const unit = useModel('units', unitId) || {};
const coursewareUrl = unit.legacyWebUrl || `${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware/`;
global.location.assign(coursewareUrl);
return null;
}

View File

@@ -5,7 +5,6 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { PageRoute } from '@edx/frontend-platform/react';
import PageLoading from '../generic/PageLoading';
import CoursewareRedirect from './CoursewareRedirect';
export default () => {
const { path } = useRouteMatch();
@@ -23,7 +22,9 @@ export default () => {
<Switch>
<PageRoute
path={`${path}/courseware/:courseId/unit/:unitId`}
component={CoursewareRedirect}
render={({ match }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/jump_to/${match.params.unitId}?experience=legacy`);
}}
/>
<PageRoute
path={`${path}/course-home/:courseId`}

View File

@@ -3,19 +3,20 @@ import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { useDispatch } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { breakpoints, useWindowSize } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages';
import Sequence from './sequence';
import { CelebrationModal, shouldCelebrateOnSectionLoad } from './celebration';
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
import ContentTools from './content-tools';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import NotificationTrigger from './NotificationTrigger';
import SidebarProvider from './sidebar/SidebarContextProvider';
import SidebarTriggers from './sidebar/SidebarTriggers';
import { useModel } from '../../generic/model-store';
import useWindowSize, { responsiveBreakpoints } from '../../generic/tabs/useWindowSize';
import { getLocalStorage, setLocalStorage } from '../../data/localStorage';
import { getSessionStorage, setSessionStorage } from '../../data/sessionStorage';
/** [MM-P2P] Experiment */
import { initCoursewareMMP2P, MMP2PBlockModal } from '../../experiments/mm-p2p';
@@ -27,8 +28,13 @@ function Course({
nextSequenceHandler,
previousSequenceHandler,
unitNavigationHandler,
windowWidth,
}) {
const course = useModel('coursewareMeta', courseId);
const {
celebrations,
isStaff,
} = useModel('courseHomeMeta', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sequence ? sequence.sectionId : null);
@@ -38,73 +44,54 @@ function Course({
course,
].filter(element => element != null).map(element => element.title);
const {
celebrations,
verifiedMode,
} = course;
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
const dispatch = useDispatch();
const celebrateFirstSection = celebrations && celebrations.firstSection;
const celebrationOpen = shouldCelebrateOnSectionLoad(
courseId, sequenceId, unitId, celebrateFirstSection, dispatch, celebrations,
const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(shouldCelebrateOnSectionLoad(
courseId, sequenceId, celebrateFirstSection, dispatch, celebrations,
));
// If streakLengthToCelebrate is populated, that modal takes precedence. Wait til the next load to display
// the weekly goal celebration modal.
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal,
);
const shouldDisplayTriggers = windowWidth >= breakpoints.small.minWidth;
const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek;
const shouldDisplayNotificationTrigger = useWindowSize().width >= responsiveBreakpoints.small.minWidth;
// Responsive breakpoints for showing the notification button/tray
const shouldDisplayNotificationTrayOpenOnLoad = windowWidth > breakpoints.medium.minWidth;
const shouldDisplayNotificationTrayOpen = useWindowSize().width > responsiveBreakpoints.medium.minWidth;
const [notificationTrayVisible, setNotificationTray] = verifiedMode
&& shouldDisplayNotificationTrayOpen ? useState(true) : useState(false);
const isNotificationTrayVisible = () => notificationTrayVisible && setNotificationTray;
const toggleNotificationTray = () => {
if (notificationTrayVisible) { setNotificationTray(false); } else { setNotificationTray(true); }
};
if (!getLocalStorage('notificationStatus')) {
setLocalStorage('notificationStatus', 'active'); // Show red dot on notificationTrigger until seen
// Course specific notification tray open/closed persistance by browser session
if (!getSessionStorage(`notificationTrayStatus.${courseId}`)) {
if (shouldDisplayNotificationTrayOpenOnLoad) {
setSessionStorage(`notificationTrayStatus.${courseId}`, 'open');
} else {
// responsive version displays the tray closed on initial load, set the sessionStorage to closed
setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed');
}
}
if (!getLocalStorage('upgradeNotificationCurrentState')) {
setLocalStorage('upgradeNotificationCurrentState', 'initialize');
}
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage('notificationStatus'));
const [upgradeNotificationCurrentState, setupgradeNotificationCurrentState] = useState(getLocalStorage('upgradeNotificationCurrentState'));
const onNotificationSeen = () => {
setNotificationStatus('inactive');
setLocalStorage('notificationStatus', 'inactive');
};
/** [MM-P2P] Experiment */
const MMP2P = initCoursewareMMP2P(courseId, sequenceId, unitId);
return (
<>
<SidebarProvider courseId={courseId} unitId={unitId}>
<Helmet>
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
</Helmet>
<div className="position-relative">
<div className="position-relative d-flex align-items-start">
<CourseBreadcrumbs
courseId={courseId}
sectionId={section ? section.id : null}
sequenceId={sequenceId}
isStaff={course ? course.isStaff : null}
isStaff={isStaff}
unitId={unitId}
//* * [MM-P2P] Experiment */
mmp2p={MMP2P}
/>
{ shouldDisplayNotificationTrigger ? (
<NotificationTrigger
toggleNotificationTray={toggleNotificationTray}
isNotificationTrayVisible={isNotificationTrayVisible}
notificationStatus={notificationStatus}
setNotificationStatus={setNotificationStatus}
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
/>
) : null}
{shouldDisplayTriggers && (
<SidebarTriggers />
)}
</div>
<AlertList topic="sequence" />
@@ -115,27 +102,24 @@ function Course({
unitNavigationHandler={unitNavigationHandler}
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
toggleNotificationTray={toggleNotificationTray}
isNotificationTrayVisible={isNotificationTrayVisible}
notificationTrayVisible={notificationTrayVisible}
notificationStatus={notificationStatus}
setNotificationStatus={setNotificationStatus}
onNotificationSeen={onNotificationSeen}
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
//* * [MM-P2P] Experiment */
mmp2p={MMP2P}
/>
{celebrationOpen && (
<CelebrationModal
courseId={courseId}
open
/>
)}
<CelebrationModal
courseId={courseId}
isOpen={firstSectionCelebrationOpen}
onClose={() => setFirstSectionCelebrationOpen(false)}
/>
<WeeklyGoalCelebrationModal
courseId={courseId}
daysPerWeek={daysPerWeek}
isOpen={weeklyGoalCelebrationOpen}
onClose={() => setWeeklyGoalCelebrationOpen(false)}
/>
<ContentTools course={course} />
{ /** [MM-P2P] Experiment */ }
{ MMP2P.meta.modalLock && <MMP2PBlockModal options={MMP2P} /> }
</>
</SidebarProvider>
);
}
@@ -146,6 +130,7 @@ Course.propTypes = {
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
unitNavigationHandler: PropTypes.func.isRequired,
windowWidth: PropTypes.number.isRequired,
};
Course.defaultProps = {
@@ -154,4 +139,18 @@ Course.defaultProps = {
unitId: null,
};
export default Course;
function CourseWrapper(props) {
// useWindowSize initially returns an undefined width intentionally at first.
// See https://www.joshwcomeau.com/react/the-perils-of-rehydration/ for why.
// But <Course> has some tricky window-size-dependent, session-storage-setting logic and React would yell at us if
// we exited that component early, before hitting all the useState() calls.
// So just skip all that until we have a window size available.
const windowWidth = useWindowSize().width;
if (windowWidth === undefined) {
return null;
}
return <Course {...props} windowWidth={windowWidth} />;
}
export default CourseWrapper;

View File

@@ -1,22 +1,22 @@
import React from 'react';
import { Factory } from 'rosie';
import { breakpoints } from '@edx/paragon';
import {
loadUnit, render, screen, waitFor, getByRole, initializeTestStore, fireEvent,
fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
} from '../../setupTest';
import Course from './Course';
import { handleNextSectionCelebration } from './celebration';
import * as celebrationUtils from './celebration/utils';
import useWindowSize from '../../generic/tabs/useWindowSize';
import Course from './Course';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('../../generic/tabs/useWindowSize');
useWindowSize.mockReturnValue({ width: 1200 });
const recordFirstSectionCelebration = jest.fn();
celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
describe('Course', () => {
let store;
let getItemSpy;
let setItemSpy;
const mockData = {
nextSequenceHandler: () => {},
previousSequenceHandler: () => {},
@@ -32,6 +32,14 @@ describe('Course', () => {
sequenceId,
unitId: Object.values(models.units)[0].id,
});
getItemSpy = jest.spyOn(Object.getPrototypeOf(window.sessionStorage), 'getItem');
setItemSpy = jest.spyOn(Object.getPrototypeOf(window.sessionStorage), 'setItem');
global.innerWidth = breakpoints.extraLarge.minWidth;
});
afterAll(() => {
getItemSpy.mockRestore();
setItemSpy.mockRestore();
});
it('loads learning sequence', async () => {
@@ -55,13 +63,9 @@ describe('Course', () => {
);
});
it('displays celebration modal', async () => {
// TODO: Remove these console mocks after merging https://github.com/edx/paragon/pull/526.
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
const courseMetadata = Factory.build('courseMetadata', { celebrations: { firstSection: true } });
const testStore = await initializeTestStore({ courseMetadata }, false);
it('displays first section celebration modal', async () => {
const courseHomeMetadata = Factory.build('courseHomeMetadata', { celebrations: { firstSection: true } });
const testStore = await initializeTestStore({ courseHomeMetadata }, false);
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
@@ -74,21 +78,86 @@ describe('Course', () => {
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
render(<Course {...testData} />, { store: testStore });
const celebrationModal = screen.getByRole('dialog');
expect(celebrationModal).toBeInTheDocument();
expect(getByRole(celebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
const firstSectionCelebrationModal = screen.getByRole('dialog');
expect(firstSectionCelebrationModal).toBeInTheDocument();
expect(getByRole(firstSectionCelebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
});
it('displays weekly goal celebration modal', async () => {
const courseHomeMetadata = Factory.build('courseHomeMetadata', { celebrations: { weeklyGoal: true } });
const testStore = await initializeTestStore({ courseHomeMetadata }, false);
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
};
render(<Course {...testData} />, { store: testStore });
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
expect(getByRole(weeklyGoalCelebrationModal, 'heading', { name: 'You met your goal!' })).toBeInTheDocument();
});
it('displays notification trigger and toggles active class on click', async () => {
useWindowSize.mockReturnValue({ width: 1200 });
render(<Course {...mockData} />);
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
expect(notificationTrigger).toBeInTheDocument();
expect(notificationTrigger).toHaveClass('trigger-active');
expect(notificationTrigger.parentNode).toHaveClass('border-primary-700');
fireEvent.click(notificationTrigger);
expect(notificationTrigger).not.toHaveClass('trigger-active');
expect(notificationTrigger.parentNode).not.toHaveClass('border-primary-700');
});
it('handles click to open/close notification tray', async () => {
sessionStorage.clear();
render(<Course {...mockData} />);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();
fireEvent.click(notificationShowButton);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
});
it('handles reload persisting notification tray status', async () => {
sessionStorage.clear();
render(<Course {...mockData} />);
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
fireEvent.click(notificationShowButton);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
// Mock reload window, this doesn't happen in the Course component,
// calling the reload to check if the tray remains closed
const { location } = window;
delete window.location;
window.location = { reload: jest.fn() };
window.location.reload();
expect(window.location.reload).toHaveBeenCalled();
window.location = location;
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
expect(screen.queryByTestId('NotificationTray')).not.toBeInTheDocument();
});
it('handles sessionStorage from a different course for the notification tray', async () => {
sessionStorage.clear();
const courseMetadataSecondCourse = Factory.build('courseMetadata', { id: 'second_course' });
// set sessionStorage for a different course before rendering Course
sessionStorage.setItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`, '"open"');
render(<Course {...mockData} />);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
fireEvent.click(notificationShowButton);
// Verify sessionStorage was updated for the original course
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
// Verify the second course sessionStorage was not changed
expect(sessionStorage.getItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`)).toBe('"open"');
});
it('renders course breadcrumbs as expected', async () => {
@@ -112,8 +181,8 @@ describe('Course', () => {
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
// expect the section and sequence "titles" to be loaded in as breadcrumb labels.
expect(screen.getByText('cdabcdabcdabcdabcdabcdabcdabcd13')).toBeInTheDocument();
expect(screen.getByText('cdabcdabcdabcdabcdabcdabcdabcd12')).toBeInTheDocument();
expect(screen.getByText(Object.values(models.sections)[0].title)).toBeInTheDocument();
expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
});
it('passes handlers to the sequence', async () => {
@@ -151,4 +220,94 @@ describe('Course', () => {
expect(nextSequenceHandler).not.toHaveBeenCalled();
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
});
describe('Sequence alerts display', () => {
it('renders banner text alert', async () => {
const courseMetadata = Factory.build('courseMetadata');
const sequenceBlocks = [Factory.build(
'block', { type: 'sequential', banner_text: 'Some random banner text to display.' },
)];
const sequenceMetadata = [Factory.build(
'sequenceMetadata', { banner_text: sequenceBlocks[0].banner_text },
{ courseId: courseMetadata.id, sequenceBlock: sequenceBlocks[0] },
)];
const testStore = await initializeTestStore({ courseMetadata, sequenceBlocks, sequenceMetadata });
const testData = {
...mockData,
courseId: courseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore });
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
});
it('renders Entrance Exam alert with passing score', async () => {
const sectionId = 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@entrance_exam';
const testCourseMetadata = Factory.build('courseMetadata', {
entrance_exam_data: {
entrance_exam_current_score: 1.0,
entrance_exam_enabled: true,
entrance_exam_id: sectionId,
entrance_exam_minimum_score_pct: 0.7,
entrance_exam_passed: true,
},
});
const sequenceBlocks = [Factory.build(
'block',
{ type: 'sequential', sectionId },
{ courseId: testCourseMetadata.id },
)];
const sectionBlocks = [Factory.build(
'block',
{ type: 'chapter', children: sequenceBlocks.map(block => block.id), id: sectionId },
{ courseId: testCourseMetadata.id },
)];
const testStore = await initializeTestStore({
courseMetadata: testCourseMetadata, sequenceBlocks, sectionBlocks,
});
const testData = {
...mockData,
courseId: testCourseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore });
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
});
it('renders Entrance Exam alert with non-passing score', async () => {
const sectionId = 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@entrance_exam';
const testCourseMetadata = Factory.build('courseMetadata', {
entrance_exam_data: {
entrance_exam_current_score: 0.3,
entrance_exam_enabled: true,
entrance_exam_id: sectionId,
entrance_exam_minimum_score_pct: 0.7,
entrance_exam_passed: false,
},
});
const sequenceBlocks = [Factory.build(
'block',
{ type: 'sequential', sectionId },
{ courseId: testCourseMetadata.id },
)];
const sectionBlocks = [Factory.build(
'block',
{ type: 'chapter', children: sequenceBlocks.map(block => block.id), id: sectionId },
{ courseId: testCourseMetadata.id },
)];
const testStore = await initializeTestStore({
courseMetadata: testCourseMetadata, sequenceBlocks, sectionBlocks,
});
const testData = {
...mockData,
courseId: testCourseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore });
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
});
});
});

View File

@@ -1,24 +1,25 @@
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHome } from '@fortawesome/free-solid-svg-icons';
import { useSelector } from 'react-redux';
import { SelectMenu } from '@edx/paragon';
import { Link } from 'react-router-dom';
import { useModel, useModels } from '../../generic/model-store';
/** [MM-P2P] Experiment */
import { MMP2PFlyoverTrigger } from '../../experiments/mm-p2p';
import JumpNavMenuItem from './JumpNavMenuItem';
function CourseBreadcrumb({
content, withSeparator, courseId, unitId, isStaff,
content, withSeparator, courseId, sequenceId, unitId, isStaff,
}) {
const defaultContent = content.filter(destination => destination.default)[0] || { id: courseId, label: '' };
const defaultContent = content.filter(destination => destination.default)[0] || { id: courseId, label: '', sequences: [] };
return (
<>
{withSeparator && (
<li className="mx-2 text-primary-500 text-truncate text-nowrap" role="presentation" aria-hidden>/</li>
<li className="col-auto p-0 mx-2 text-primary-500 text-truncate text-nowrap" role="presentation" aria-hidden>/</li>
)}
<li style={{
@@ -27,11 +28,16 @@ function CourseBreadcrumb({
whiteSpace: 'nowrap',
}}
>
{content.length < 2 || !isStaff
{ getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff
? (
<a className="text-primary-500" href={`/course/${courseId}/${defaultContent.id}`}>
<Link
className="text-primary-500"
to={defaultContent.sequences.length
? `/course/${courseId}/${defaultContent.sequences[0].id}`
: `/course/${courseId}/${defaultContent.id}`}
>
{defaultContent.label}
</a>
</Link>
)
: (
<SelectMenu isLink defaultMessage={defaultContent.label}>
@@ -41,6 +47,7 @@ function CourseBreadcrumb({
sequences={item.sequences}
courseId={courseId}
title={item.label}
currentSequence={sequenceId}
currentUnit={unitId}
/>
))}
@@ -59,6 +66,7 @@ CourseBreadcrumb.propTypes = {
label: PropTypes.string,
}),
).isRequired,
sequenceId: PropTypes.string,
unitId: PropTypes.string,
withSeparator: PropTypes.bool,
courseId: PropTypes.string,
@@ -67,6 +75,7 @@ CourseBreadcrumb.propTypes = {
CourseBreadcrumb.defaultProps = {
withSeparator: false,
sequenceId: null,
unitId: null,
courseId: null,
isStaff: null,
@@ -120,10 +129,10 @@ export default function CourseBreadcrumbs({
return (
<nav aria-label="breadcrumb" className="my-4 d-inline-block col-sm-10">
<ol className="list-unstyled d-flex flex-nowrap align-items-center m-0">
<li className="list-unstyled d-flex m-0">
<a
href={`/course/${courseId}/home`}
<li className="list-unstyled col-auto m-0 p-0">
<Link
className="flex-shrink-0 text-primary"
to={`/course/${courseId}/home`}
>
<FontAwesomeIcon icon={faHome} className="mr-2" />
<FormattedMessage
@@ -131,7 +140,7 @@ export default function CourseBreadcrumbs({
description="The course home link in breadcrumbs nav"
defaultMessage="Course"
/>
</a>
</Link>
</li>
{links.map(content => (
<CourseBreadcrumb

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { screen, render } from '@testing-library/react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import { BrowserRouter } from 'react-router-dom';
import { useModel, useModels } from '../../generic/model-store';
import CourseBreadcrumbs from './CourseBreadcrumbs';
@@ -10,6 +12,7 @@ jest.mock('@edx/frontend-platform/analytics');
// Remove When Fully rolled out>>>
jest.mock('../../generic/model-store');
jest.mock('@edx/frontend-platform/auth');
getConfig.mockImplementation(() => ({ ENABLE_JUMPNAV: 'true' }));
getAuthenticatedUser.mockImplementation(() => ({ administrator: true }));
// ^^^^Remove When Fully rolled out
@@ -103,12 +106,14 @@ describe('CourseBreadcrumbs', () => {
],
]);
render(
<CourseBreadcrumbs
courseId="course-v1:edX+DemoX+Demo_Course"
sectionId="block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations"
sequenceId="block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions"
isStaff
/>,
<BrowserRouter>
<CourseBreadcrumbs
courseId="course-v1:edX+DemoX+Demo_Course"
sectionId="block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations"
sequenceId="block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions"
isStaff
/>
</BrowserRouter>,
);
it('renders course breadcrumbs as expected', async () => {
expect(screen.queryAllByRole('link')).toHaveLength(1);

View File

@@ -1,4 +1,3 @@
/* eslint-disable consistent-return */
import React from 'react';
import PropTypes from 'prop-types';
import { history } from '@edx/frontend-platform';
@@ -8,18 +7,15 @@ import {
sendTrackingLogEvent,
sendTrackEvent,
} from '@edx/frontend-platform/analytics';
import { useDispatch } from 'react-redux';
import { checkBlockCompletion } from '../data';
export default function JumpNavMenuItem({
title,
courseId,
currentSequence,
currentUnit,
sequences,
isDefault,
}) {
const dispatch = useDispatch();
function logEvent(targetUrl) {
const eventName = 'edx.ui.lms.jump_nav.selected';
const payload = {
@@ -32,22 +28,14 @@ export default function JumpNavMenuItem({
sendTrackingLogEvent(eventName, payload);
}
function lazyloadUrl() {
function destinationUrl() {
if (isDefault) {
return `/course/${courseId}/${currentUnit}`;
return `/course/${courseId}/${currentSequence}/${currentUnit}`;
}
const destinationString = sequences.forEach(sequence => sequence.unitIds.forEach(unitId => {
const complete = dispatch(checkBlockCompletion(
courseId,
sequence.id, unitId,
))
.then(value => value);
if (!complete) { return `/course/${courseId}/${unitId}`; }
}));
return destinationString || `/course/${courseId}/${sequences[0].unitIds[0]}`;
return `/course/${courseId}/${sequences[0].id}`;
}
function handleClick() {
const url = lazyloadUrl();
const url = destinationUrl();
logEvent(url);
history.push(url);
}
@@ -64,11 +52,6 @@ export default function JumpNavMenuItem({
const sequenceShape = PropTypes.shape({
id: PropTypes.string.isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
sectionId: PropTypes.string.isRequired,
isTimeLimited: PropTypes.bool,
isProctored: PropTypes.bool,
legacyWebUrl: PropTypes.string,
});
JumpNavMenuItem.propTypes = {
@@ -76,5 +59,6 @@ JumpNavMenuItem.propTypes = {
sequences: PropTypes.arrayOf(sequenceShape).isRequired,
isDefault: PropTypes.bool.isRequired,
courseId: PropTypes.string.isRequired,
currentSequence: PropTypes.string.isRequired,
currentUnit: PropTypes.string.isRequired,
};

View File

@@ -6,12 +6,6 @@ import { fireEvent } from '../../setupTest';
jest.mock('@edx/frontend-platform');
jest.mock('@edx/frontend-platform/analytics');
const mockCheckBlock = jest.fn(() => Promise.resolve(true)); // check all units
jest.mock('react-redux', () => ({
useDispatch: () => mockCheckBlock,
}));
const mockData = {
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
sequenceId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
@@ -22,33 +16,9 @@ const mockData = {
sequences: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5',
blockType: 'sequential',
unitIds: [
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4f6c1b4e316a419ab5b6bf30e6c708e9',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@3dc16db8d14842e38324e95d4030b8a0',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4a1bba2a403f40bca5ec245e945b0d76',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@256f17a44983429fb1a60802203ee4e0',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@e3601c0abee6427d8c17e6d6f8fdddd1',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@134df56c516a4a0dbb24dd5facef746e',
],
legacyWebUrl: 'http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5?experience=legacy',
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
title: 'Homework - Question Styles',
legacyWebUrl: 'http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions?experience=legacy',
unitIds: [
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@47dbd5f836544e61877a483c0b75606c',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@54bb9b142c6c4c22afc62bcb628f0e68',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0c92347a5c00',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_1fef54c2b23b',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2889db1677a549abb15eb4d886f95d1c',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@e8a5cc2aed424838853defab7be45e42',
],
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
},
],
isDefault: false,
@@ -64,6 +34,5 @@ describe('JumpNavMenuItem', () => {
expect(screen.getByText('Demo Menu Item'));
const navButton = screen.queryAllByRole('button')[0];
fireEvent.click(navButton);
expect(mockCheckBlock).toBeCalledTimes(14); // number of units to check on load.
});
});

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