Compare commits

...

94 Commits

Author SHA1 Message Date
Peter Pinch
a21abde463 squash! update package-lock.json 2021-12-20 17:55:30 +01:00
Peter Pinch
37d9646629 chore: update frontend-component-header 2.4.3 2021-12-20 17:55:30 +01:00
Peter Pinch
ad72980ad7 chore: update frontend-build to 9.0.5 2021-12-20 17:55:30 +01:00
Peter Pinch
71bcb6ba62 squash! remove duplicate code from cherry pick 2021-12-20 17:55:30 +01:00
Asad Iqbal
da867d0ef6 feat: Removed course header stuff (#715) 2021-12-20 17:55:30 +01:00
Asad Iqbal
131096b4a5 chore: update paragon 2021-12-20 17:55:30 +01:00
Régis Behmo
76e83cc737 fix: Use Link from router to fix path based routing issue (#780)
Co-authored-by: Arslan <arslan.ashraf@arbisoft.com>
2021-12-20 15:40:25 +00:00
renovate[bot]
83fa3f78bc fix(deps): update dependency @edx/paragon to v16.14.9 (#683)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-15 09:08:25 -04:00
renovate[bot]
1e4f3ec151 chore(deps): update dependency @testing-library/user-event to v13.4.1 (#684)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-15 09:08:08 -04:00
renovate[bot]
1ac806b7dd fix(deps): update dependency js-cookie to v3 (#596)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: Michael Terry <mterry@edx.org>
2021-10-14 15:57:03 -04:00
renovate[bot]
1d08618be9 fix(deps): update dependency core-js to v3.18.3 (#627)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-14 15:40:12 -04:00
renovate[bot]
b90a54759c chore(deps): update dependency jest to v27.2.5 (#619)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-14 15:29:41 -04:00
renovate[bot]
a1ef37ca0b fix(deps): update dependency react-router-dom to v5.3.0 (#629)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-14 15:27:39 -04:00
renovate[bot]
d178913e4b chore(deps): update dependency glob to v7.2.0 (#649)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-14 15:26:30 -04:00
renovate[bot]
9f2ce9d152 chore(deps): update dependency @testing-library/user-event to v13.3.0 (#679)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-14 15:25:22 -04:00
renovate[bot]
d6722ca271 fix(deps): update dependency @edx/paragon to v16.14.7 (#644)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-14 15:21:27 -04:00
renovate[bot]
aa2004434e fix(deps): update dependency @edx/frontend-enterprise-utils to v1.1.0 (#682)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-14 15:18:56 -04:00
Renovate Bot
921f3eef06 fix(deps): update dependency @pact-foundation/pact to v9.16.4 2021-10-11 00:04:09 +00:00
edX Transifex Bot
8337fc79be chore(i18n): update translations 2021-10-11 02:10:22 +05:00
connorhaugh
2230173da8 feat: rollout jump nav to course staff (#672)
As per https://openedx.atlassian.net/browse/TNL-7107 we need to rollout jumpnav to course staff.
2021-10-08 09:33:15 -04:00
Thomas Tracy
09072f318b fix: Fix redirect loop (#676)
MICROBA-1523
2021-10-07 16:35:08 -04:00
Michael Terry
1f7cb2cc28 fix: move NewRelic testing script even higher (#675)
Per their docs, this really should be the first script. And we were
hitting some cloudflare scripts being inserted ahead of us. This
might fix that.
2021-10-07 14:44:32 -04:00
Ned Batchelder
c6ad7c51d3 build: use the organization commitlint check 2021-10-07 13:51:59 -04:00
Andrew Shultz
20d4de09d7 fix: upgrade the frontend-lib-special-exams library to v1.13.3 (#674)
prevents confusing exam errors in non-exam situations

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3
.env
View File

@@ -4,6 +4,7 @@
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=''
BASE_URL=''
CONTACT_URL=''
CREDENTIALS_BASE_URL=''
CSRF_TOKEN_API_PATH=''
DISCOVERY_API_BASE_URL=''
@@ -36,3 +37,5 @@ TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
SESSION_COOKIE_DOMAIN=''
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''

View File

@@ -4,6 +4,7 @@
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'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
@@ -36,3 +37,5 @@ TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''

View File

@@ -4,6 +4,7 @@
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'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
@@ -35,3 +36,5 @@ 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_JUMPNAV='true'
ENABLE_NOTICES=''

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

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

3
.gitignore vendored
View File

@@ -20,3 +20,6 @@ logs
# Local package dependencies
module.config.js
# Local environment overrides
.env.private

View File

@@ -109,3 +109,9 @@ TWITTER_URL
unless this is set. Optional.
Example: https://twitter.com/edXOnline
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

View File

@@ -1,5 +1,7 @@
# Courseware Page Decisions
**See [0009-courseware-api-direction.md](0009-courseware-api-direction.md) for updates!**
## Courseware data loading
Today we have strictly hierarchical courses - a course contains sections, which contain sequences, which contain units, which contain components.

View File

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

9077
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,29 +33,30 @@
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.1.6",
"@edx/frontend-enterprise-utils": "0.1.7",
"@edx/frontend-lib-special-exams": "1.12.0",
"@edx/frontend-platform": "1.12.3",
"@edx/paragon": "16.7.0",
"@edx/frontend-enterprise-utils": "1.1.1",
"@edx/frontend-lib-special-exams": "1.14.1",
"@edx/frontend-platform": "1.14.3",
"@edx/paragon": "16.19.0",
"@edx/frontend-component-header": "^2.4.3",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@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.0",
"@reduxjs/toolkit": "1.6.1",
"@pact-foundation/pact": "9.16.4",
"@reduxjs/toolkit": "1.6.2",
"classnames": "2.3.1",
"core-js": "3.16.1",
"js-cookie": "2.2.1",
"core-js": "3.18.3",
"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",
"react-helmet": "6.1.0",
"react-redux": "7.2.4",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-redux": "7.2.5",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"react-share": "4.4.0",
"redux": "4.1.1",
"regenerator-runtime": "0.13.9",
@@ -64,17 +65,17 @@
"util": "0.12.4"
},
"devDependencies": {
"@edx/frontend-build": "8.0.0",
"@edx/frontend-build": "9.0.5",
"@testing-library/dom": "7.16.3",
"@testing-library/jest-dom": "5.14.1",
"@testing-library/react": "10.3.0",
"@testing-library/user-event": "12.8.3",
"axios-mock-adapter": "1.19.0",
"@testing-library/user-event": "13.4.1",
"axios-mock-adapter": "1.20.0",
"codecov": "3.8.3",
"es-check": "5.2.4",
"glob": "7.1.7",
"husky": "7.0.1",
"jest": "27.0.6",
"es-check": "6.0.0",
"glob": "7.2.0",
"husky": "7.0.2",
"jest": "27.2.5",
"jest-chain": "1.1.5",
"reactifex": "1.1.1",
"rosie": "2.1.0"

File diff suppressed because one or more lines are too long

View File

@@ -9,7 +9,6 @@ import { Info } from '@edx/paragon/icons';
import messages from './messages';
import AccessExpirationAlertMMP2P from './AccessExpirationAlertMMP2P';
import AccessExpirationAlertMasquerade from './AccessExpirationAlertMasquerade';
function AccessExpirationAlert({ intl, payload }) {
/** [MM-P2P] Experiment */
@@ -36,17 +35,10 @@ function AccessExpirationAlert({ intl, payload }) {
const {
expirationDate,
masqueradingExpiredCourse,
upgradeDeadline,
upgradeUrl,
} = accessExpiration;
if (masqueradingExpiredCourse) {
return (
<AccessExpirationAlertMasquerade payload={payload} />
);
}
/** [MM-P2P] Experiment */
if (showMMP2P) {
return (

View File

@@ -1,60 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
function AccessExpirationAlertMasquerade({ payload }) {
const {
accessExpiration,
userTimezone,
} = payload;
if (!accessExpiration) {
return null;
}
const {
expirationDate,
masqueradingExpiredCourse,
} = accessExpiration;
if (!masqueradingExpiredCourse) {
return null;
}
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<Alert variant="info" icon={Info}>
<FormattedMessage
id="learning.accessExpiration.expired"
defaultMessage="This learner does not have access to this course. Their access expired on {date}."
values={{
date: (
<FormattedDate
key="accessExpirationExpiredDate"
day="numeric"
month="short"
year="numeric"
value={expirationDate}
{...timezoneFormatArgs}
/>
),
}}
/>
</Alert>
);
}
AccessExpirationAlertMasquerade.propTypes = {
payload: PropTypes.shape({
accessExpiration: PropTypes.shape({
expirationDate: PropTypes.string.isRequired,
masqueradingExpiredCourse: PropTypes.bool.isRequired,
}).isRequired,
userTimezone: PropTypes.string.isRequired,
}).isRequired,
};
export default AccessExpirationAlertMasquerade;

View File

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

View File

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

View File

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

View File

@@ -9,14 +9,20 @@ import {
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { useModel } from '../../generic/model-store';
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
function CourseStartAlert({ payload }) {
const {
startDate,
userTimezone,
courseId,
} = payload;
const {
start: startDate,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const timeRemaining = (
@@ -87,8 +93,7 @@ function CourseStartAlert({ payload }) {
CourseStartAlert.propTypes = {
payload: PropTypes.shape({
startDate: PropTypes.string,
userTimezone: PropTypes.string,
courseId: PropTypes.string,
}).isRequired,
};

View File

@@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@edx/paragon';
import { useModel } from '../../generic/model-store';
function CourseStartMasqueradeBanner({ payload }) {
const {
courseId,
} = payload;
const {
start,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<PageBanner variant="warning">
<FormattedMessage
id="instructorToolbar.pageBanner.courseHasNotStarted"
defaultMessage="This learner does not yet have access to this course. The course starts on {date}."
values={{
date: <FormattedDate
key="instructorToolbar.pageBanner.courseStartDate"
value={start}
{...timezoneFormatArgs}
/>,
}}
/>
</PageBanner>
);
}
CourseStartMasqueradeBanner.propTypes = {
payload: PropTypes.shape({
courseId: PropTypes.string.isRequired,
}).isRequired,
};
export default CourseStartMasqueradeBanner;

View File

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

View File

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

View File

@@ -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,97 +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,
}) {
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>
{authenticatedUser && (
<AuthenticatedUserDropdown
enterpriseLearnerPortalLink={enterpriseLearnerPortalLink}
username={authenticatedUser.username}
/>
)}
{!authenticatedUser && (
<AnonymousUserMenu />
)}
</div>
</header>
);
}
Header.propTypes = {
courseOrg: PropTypes.string,
courseNumber: PropTypes.string,
courseTitle: PropTypes.string,
intl: intlShape.isRequired,
};
Header.defaultProps = {
courseOrg: null,
courseNumber: null,
courseTitle: null,
};
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

@@ -19,4 +19,5 @@ Factory.define('courseHomeMetadata')
user_message: null,
},
start: '2013-02-05T05:00:00Z',
user_timezone: 'UTC',
});

View File

@@ -219,5 +219,4 @@ Factory.define('datesTabData')
],
has_ended: false,
learner_is_full_access: true,
user_timezone: 'America/New_York',
});

View File

@@ -14,7 +14,6 @@ Factory.define('outlineTabData')
})
.attr('dates_widget', ['date_blocks'], (dateBlocks) => ({
course_date_blocks: dateBlocks,
user_timezone: 'UTC',
}))
.attr('resume_course', ['host', 'courseId'], (host, courseId) => ({
has_visited_course: false,
@@ -48,11 +47,6 @@ Factory.define('outlineTabData')
title: 'Bookmarks',
url: 'https://example.com/bookmarks',
},
{
analytics_id: 'edx.tool.verified_upgrade',
title: 'Upgrade to Verified',
url: 'https://example.com/upgrade',
},
],
dates_banner_info: {
content_type_gating_enabled: false,

View File

@@ -4,6 +4,7 @@ import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dep
// This set of data may not be realistic, but it is intended to demonstrate many UI results.
Factory.define('progressTabData')
.attrs({
access_expiration: null,
end: '3027-03-31T00:00:00Z',
certificate_data: {},
completion_summary: {

View File

@@ -13,10 +13,8 @@ Object {
"courseware": Object {
"courseId": null,
"courseStatus": "loading",
"proctoredExamsEnabledWaffleFlag": false,
"sequenceId": null,
"sequenceStatus": "loading",
"specialExamsEnabledWaffleFlag": false,
},
"models": Object {
"courseHomeMeta": Object {
@@ -32,6 +30,7 @@ Object {
},
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
@@ -71,6 +70,7 @@ Object {
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"verifiedMode": Object {
"currencySymbol": "$",
"price": 10,
@@ -295,7 +295,6 @@ Object {
"hasEnded": false,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"learnerIsFullAccess": true,
"userTimezone": "America/New_York",
},
},
},
@@ -318,10 +317,8 @@ Object {
"courseware": Object {
"courseId": null,
"courseStatus": "loading",
"proctoredExamsEnabledWaffleFlag": false,
"sequenceId": null,
"sequenceStatus": "loading",
"specialExamsEnabledWaffleFlag": false,
},
"models": Object {
"courseHomeMeta": Object {
@@ -337,6 +334,7 @@ Object {
},
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
@@ -376,6 +374,7 @@ Object {
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"verifiedMode": Object {
"currencySymbol": "$",
"price": 10,
@@ -442,11 +441,6 @@ Object {
"title": "Bookmarks",
"url": "https://example.com/bookmarks",
},
Object {
"analyticsId": "edx.tool.verified_upgrade",
"title": "Upgrade to Verified",
"url": "https://example.com/upgrade",
},
],
"datesBannerInfo": Object {
"contentTypeGatingEnabled": false,
@@ -455,7 +449,6 @@ Object {
},
"datesWidget": Object {
"courseDateBlocks": Array [],
"userTimezone": "UTC",
},
"enrollAlert": Object {
"canEnroll": true,
@@ -504,10 +497,8 @@ Object {
"courseware": Object {
"courseId": null,
"courseStatus": "loading",
"proctoredExamsEnabledWaffleFlag": false,
"sequenceId": null,
"sequenceStatus": "loading",
"specialExamsEnabledWaffleFlag": false,
},
"models": Object {
"courseHomeMeta": Object {
@@ -523,6 +514,7 @@ Object {
},
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
@@ -562,6 +554,7 @@ Object {
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"verifiedMode": Object {
"currencySymbol": "$",
"price": 10,
@@ -571,6 +564,7 @@ Object {
},
"progress": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"accessExpiration": null,
"certificateData": Object {},
"completionSummary": Object {
"completeCount": 1,

View File

@@ -98,6 +98,7 @@ function normalizeCourseHomeCourseMetadata(metadata) {
title: tab.title,
url: tab.url,
})),
isMasquerading: data.originalUserIsStaff && !data.isStaff,
};
}
@@ -179,7 +180,7 @@ export function normalizeOutlineBlocks(courseId, blocks) {
}
export async function getCourseHomeCourseMetadata(courseId) {
let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
let url = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
url = appendBrowserTimezoneToUrl(url);
const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeCourseHomeCourseMetadata(data);
@@ -191,7 +192,7 @@ export async function getCourseHomeCourseMetadata(courseId) {
// import './__factories__';
export async function getDatesTabData(courseId) {
// return camelCaseObject(Factory.build('datesTabData'));
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
const url = `${getConfig().LMS_BASE_URL}/api/course_home/dates/${courseId}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
@@ -211,7 +212,7 @@ export async function getDatesTabData(courseId) {
}
export async function getProgressTabData(courseId, targetUserId) {
let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
let url = `${getConfig().LMS_BASE_URL}/api/course_home/progress/${courseId}`;
// If targetUserId is passed in, we will get the progress page data
// for the user with the provided id, rather than the requesting user.
@@ -312,7 +313,7 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
}
export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
let { tabData } = {};
let requestTime = Date.now();
let responseTime = requestTime;
@@ -386,12 +387,12 @@ export async function postCourseDeadlines(courseId, model) {
}
export async function postCourseGoals(courseId, goalKey) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`);
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 postDismissWelcomeMessage(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`);
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`);
await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
}
@@ -407,3 +408,9 @@ export async function executePostFromPostEvent(postData, researchEventData) {
research_event_data: researchEventData,
});
}
export async function unsubscribeFromCourseGoal(token) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/unsubscribe_from_course_goal/${token}`);
return getAuthenticatedHttpClient().post(url.href)
.then(res => camelCaseObject(res));
}

View File

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

View File

@@ -18,7 +18,7 @@ const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Data layer integration tests', () => {
const courseHomeMetadata = Factory.build('courseHomeMetadata');
const { id: courseId } = courseHomeMetadata;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
let store;
@@ -31,7 +31,7 @@ describe('Data layer integration tests', () => {
});
describe('Test fetchDatesTab', () => {
const datesBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates`;
const datesBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dates`;
it('Should fail to fetch if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError();
@@ -60,7 +60,7 @@ describe('Data layer integration tests', () => {
});
describe('Test fetchOutlineTab', () => {
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline`;
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`;
it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError();
@@ -89,7 +89,7 @@ describe('Data layer integration tests', () => {
});
describe('Test fetchProgressTab', () => {
const progressBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress`;
const progressBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/progress`;
it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError();
@@ -133,7 +133,7 @@ describe('Data layer integration tests', () => {
describe('Test saveCourseGoal', () => {
it('Should save course goal', async () => {
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`;
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
axiosMock.onPost(goalUrl).reply(200, {});
await thunks.saveCourseGoal(courseId, 'unsure');
@@ -164,7 +164,7 @@ describe('Data layer integration tests', () => {
describe('Test dismissWelcomeMessage', () => {
it('Should dismiss welcome message', async () => {
const dismissUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`;
const dismissUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`;
axiosMock.onPost(dismissUrl).reply(201);
await executeThunk(thunks.dismissWelcomeMessage(courseId), store.dispatch);

View File

@@ -43,11 +43,11 @@ describe('DatesTab', () => {
});
const datesTabData = Factory.build('datesTabData');
let courseMetadata = Factory.build('courseHomeMetadata');
let courseMetadata = Factory.build('courseHomeMetadata', { user_timezone: 'America/New_York' });
const { id: courseId } = courseMetadata;
const datesUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
const datesUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dates/${courseId}`;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
function setMetadata(attributes, options) {

View File

@@ -29,10 +29,10 @@ function Day({
const {
courseId,
} = useSelector(state => state.courseHome);
const {
userTimezone,
} = useModel('dates', courseId);
} = useModel('courseHomeMeta', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const { color, badges } = getBadgeListAndColor(date, intl, null, items);

View File

@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import PageLoading from '../../generic/PageLoading';
import { unsubscribeFromCourseGoal } from '../data/api';
import messages from './messages';
import ResultPage from './ResultPage';
function GoalUnsubscribe({ intl }) {
const { token } = useParams();
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState({});
// We don't need to bother with redux for this simple page. We're not sharing state with other pages at all.
useEffect(() => {
unsubscribeFromCourseGoal(token)
.then(
(result) => {
setIsLoading(false);
setData(result.data);
},
() => {
setIsLoading(false);
setError(true);
},
);
}, []); // deps=[] to only run once
return (
<>
<Header showUserDropdown={false} />
<main id="main-content" className="container my-5 text-center">
{isLoading && (
<PageLoading srMessage={`${intl.formatMessage(messages.loading)}`} />
)}
{!isLoading && (
<ResultPage error={error} courseTitle={data.courseTitle} />
)}
</main>
</>
);
}
GoalUnsubscribe.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(GoalUnsubscribe);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
contactSupport: {
id: 'learning.goals.unsubscribe.contact',
defaultMessage: 'contact support',
},
description: {
id: 'learning.goals.unsubscribe.description',
defaultMessage: 'You will no longer receive email reminders about your goal for {courseTitle}.',
},
errorHeader: {
id: 'learning.goals.unsubscribe.errorHeader',
defaultMessage: 'Something went wrong',
},
goToDashboard: {
id: 'learning.goals.unsubscribe.goToDashboard',
defaultMessage: 'Go to dashboard',
},
header: {
id: 'learning.goals.unsubscribe.header',
defaultMessage: 'Youve unsubscribed from goal reminders',
},
loading: {
id: 'learning.goals.unsubscribe.loading',
defaultMessage: 'Unsubscribing…',
},
});
export default messages;

View File

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

After

Width:  |  Height:  |  Size: 743 B

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { sendTrackEvent, sendTrackingLogEvent } 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';
@@ -17,11 +18,10 @@ import Section from './Section';
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpdateGoalSelector from './widgets/UpdateGoalSelector';
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
import { useAccessExpirationAlertMasquerade } from '../../alerts/access-expiration-alert';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
import useCertificateAvailableAlert from './alerts/certificate-status-alert';
import useCourseEndAlert from './alerts/course-end-alert';
import useCourseStartAlert from './alerts/course-start-alert';
import useCourseStartAlert from '../../alerts/course-start-alert';
import usePrivateCourseAlert from './alerts/private-course-alert';
import useScheduledContentAlert from './alerts/scheduled-content-alert';
import { useModel } from '../../generic/model-store';
@@ -42,6 +42,7 @@ function OutlineTab({ intl }) {
org,
title,
username,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const {
@@ -57,7 +58,6 @@ function OutlineTab({ intl }) {
datesBannerInfo,
datesWidget: {
courseDateBlocks,
userTimezone,
},
resumeCourse: {
hasVisitedCourse,
@@ -86,7 +86,6 @@ function OutlineTab({ intl }) {
};
// Below the course title alerts (appearing in the order listed here)
const accessExpirationAlertMasquerade = useAccessExpirationAlertMasquerade(accessExpiration, userTimezone, 'outline-course-alerts');
const courseStartAlert = useCourseStartAlert(courseId);
const courseEndAlert = useCourseEndAlert(courseId);
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
@@ -107,9 +106,19 @@ function OutlineTab({ intl }) {
});
};
const isEnterpriseUser = () => {
const authenticatedUser = getAuthenticatedUser();
const userRoleNames = authenticatedUser ? authenticatedUser.roles.map(role => role.split(':')[0]) : [];
return userRoleNames.includes('enterprise_learner');
};
/** [[MM-P2P] Experiment */
const MMP2P = initHomeMMP2P(courseId);
/** show post enrolment survey to only B2C learners */
const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner';
return (
<>
<Toast
@@ -119,7 +128,7 @@ function OutlineTab({ intl }) {
>
{goalToastHeader}
</Toast>
<div className="row w-100 mx-0 my-3 justify-content-between">
<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>
@@ -150,7 +159,6 @@ function OutlineTab({ intl }) {
topic="outline-course-alerts"
className="mb-3"
customAlerts={{
...accessExpirationAlertMasquerade,
...certificateAvailableAlert,
...courseEndAlert,
...courseStartAlert,

View File

@@ -16,6 +16,7 @@ import * as thunks from '../data/thunks';
import initializeStore from '../../store';
import { CERT_STATUS_TYPE } from './alerts/certificate-status-alert/CertificateStatusAlert';
import OutlineTab from './OutlineTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
@@ -24,11 +25,12 @@ describe('Outline Tab', () => {
let axiosMock;
const courseId = 'course-v1:edX+Test+run';
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
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/v1/save_course_goal`;
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
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 store = initializeStore();
@@ -57,6 +59,7 @@ describe('Outline Tab', () => {
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onPost(enrollmentUrl).reply(200, {});
axiosMock.onPost(goalUrl).reply(200, { header: 'Success' });
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
axiosMock.onGet(outlineUrl).reply(200, defaultTabData);
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'created',
@@ -437,35 +440,6 @@ describe('Outline Tab', () => {
await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Course Tools' })).not.toBeInTheDocument();
});
it('analytics sent when upgrade link clicked', async () => {
await fetchAndRender();
expect(screen.getByRole('heading', { name: 'Course Tools' })).toBeInTheDocument();
sendTrackEvent.mockClear();
sendTrackingLogEvent.mockClear();
const upgradeLink = screen.getByRole('link', { name: 'Upgrade to Verified' });
fireEvent.click(upgradeLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: '(none)',
linkName: 'course_home_course_tools',
linkType: 'link',
pageName: 'course_home',
});
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(1);
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.course.tool.accessed', {
org_key: 'edX',
courserun_key: courseId,
course_id: courseId,
is_staff: false,
tool_name: 'edx.tool.verified_upgrade',
});
});
});
describe('Alert List', () => {
@@ -521,32 +495,35 @@ describe('Outline Tab', () => {
});
describe('Access Expiration Alert', () => {
it('has special masquerade text', async () => {
it('renders page banner on masquerade', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: true,
upgrade_deadline: null,
upgrade_url: null,
},
});
await fetchAndRender();
const check = await screen.queryByText('This learner does not have access to this course.', { exact: false });
expect(check).toBeInTheDocument();
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="outline">...</LoadedTabPage>, { store }));
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
expect(instructorToolbar).toBeInTheDocument();
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
});
it('does not have special masquerade text', async () => {
it('does not render banner when not masquerading', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: false,
upgrade_deadline: null,
upgrade_url: null,
},
});
await fetchAndRender();
const check = await screen.queryByText('This learner does not have access to this course.', { exact: false });
expect(check).not.toBeInTheDocument();
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="outline">...</LoadedTabPage>, { store }));
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
expect(instructorToolbar).toBeInTheDocument();
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
});
});
@@ -555,16 +532,7 @@ describe('Outline Tab', () => {
it('appears several days out', async () => {
const startDate = new Date();
startDate.setDate(startDate.getDate() + 100);
setMetadata({ is_enrolled: true });
setTabData({}, {
date_blocks: [
{
date_type: 'course-start-date',
date: startDate.toISOString(),
title: 'Start',
},
],
});
setMetadata({ is_enrolled: true, start: '2999-01-01T00:00:00Z' });
await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
@@ -573,16 +541,7 @@ describe('Outline Tab', () => {
it('appears today', async () => {
const startDate = new Date();
startDate.setHours(startDate.getHours() + 1);
setMetadata({ is_enrolled: true });
setTabData({}, {
date_blocks: [
{
date_type: 'course-start-date',
date: startDate.toISOString(),
title: 'Start',
},
],
});
setMetadata({ is_enrolled: true, start: startDate });
await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date

View File

@@ -33,9 +33,7 @@ function SequenceLink({
title,
} = sequence;
const {
datesWidget: {
userTimezone,
},
userTimezone,
} = useModel('outline', courseId);
const {
canLoadCourseware,

View File

@@ -39,11 +39,11 @@ function useCertificateStatusAlert(courseId) {
const {
datesWidget: {
courseDateBlocks,
userTimezone,
},
certData,
hasEnded,
userHasPassingGrade,
userTimezone,
enrollmentMode,
} = useModel('outline', courseId);

View File

@@ -15,8 +15,8 @@ export function useCourseEndAlert(courseId) {
const {
datesWidget: {
courseDateBlocks,
userTimezone,
},
userTimezone,
} = useModel('outline', courseId);
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');

View File

@@ -1,37 +0,0 @@
import React, { useMemo } from 'react';
import { useAlert } from '../../../../generic/user-messages';
import { useModel } from '../../../../generic/model-store';
const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
function useCourseStartAlert(courseId) {
const {
isEnrolled,
} = useModel('courseHomeMeta', courseId);
const {
datesWidget: {
courseDateBlocks,
userTimezone,
},
} = useModel('outline', courseId);
const startBlock = courseDateBlocks.find(b => b.dateType === 'course-start-date');
const delta = startBlock ? new Date(startBlock.date) - new Date() : 0;
const isVisible = isEnrolled && startBlock && delta > 0;
const payload = {
startDate: startBlock && startBlock.date,
userTimezone,
};
useAlert(isVisible, {
code: 'clientCourseStartAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',
});
return {
clientCourseStartAlert: CourseStartAlert,
};
}
export default useCourseStartAlert;

View File

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

View File

@@ -216,6 +216,10 @@ const messages = defineMessages({
id: 'learning.proctoringPanel.reviewRequirementsButton',
defaultMessage: 'Review instructions and system requirements',
},
proctoringOnboardingButtonPastDue: {
id: 'learning.proctoringPanel.onboardingButtonPastDue',
defaultMessage: 'Onboarding Past Due',
},
});
export default messages;

View File

@@ -13,11 +13,13 @@ function CourseDates({
/** [MM-P2P] Experiment */
mmp2p,
}) {
const {
userTimezone,
} = useModel('courseHomeMeta', courseId);
const {
datesWidget: {
courseDateBlocks,
datesTabLink,
userTimezone,
},
} = useModel('outline', courseId);

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -36,16 +36,6 @@ function CourseTools({ courseId, intl }) {
is_staff: administrator,
tool_name: analyticsId,
});
if (analyticsId === 'edx.tool.verified_upgrade') {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
...eventProperties,
linkCategory: '(none)',
linkName: 'course_home_course_tools',
linkType: 'link',
pageName: 'course_home',
});
}
};
const renderIcon = (iconClasses) => {

View File

@@ -9,10 +9,12 @@ import messages from '../messages';
import { getProctoringInfoData } from '../../data/api';
function ProctoringInfoPanel({ courseId, username, intl }) {
const [status, setStatus] = useState('');
const [link, setLink] = useState('');
const [releaseDate, setReleaseDate] = useState(null);
const [onboardingPastDue, setOnboardingPastDue] = useState(false);
const [showInfoPanel, setShowInfoPanel] = useState(false);
const [status, setStatus] = useState('');
const [readableStatus, setReadableStatus] = useState('');
const [releaseDate, setReleaseDate] = useState(null);
const readableStatuses = {
notStarted: 'notStarted',
@@ -77,6 +79,10 @@ function ProctoringInfoPanel({ courseId, username, intl }) {
.then(
response => {
if (response) {
if (Object.keys(response).length > 0) {
setShowInfoPanel(true);
}
setStatus(response.onboarding_status);
setLink(response.onboarding_link);
const expirationDate = response.expiration_date;
@@ -86,14 +92,54 @@ function ProctoringInfoPanel({ courseId, username, intl }) {
setReadableStatus(getReadableStatusClass(response.onboarding_status));
}
setReleaseDate(new Date(response.onboarding_release_date));
setOnboardingPastDue(response.onboarding_past_due);
}
},
);
}, []);
let onboardingExamButton = null;
if (isNotYetReleased(releaseDate)) {
onboardingExamButton = (
<Button variant="secondary" block disabled aria-disabled="true">
{intl.formatMessage(
messages.proctoringOnboardingButtonNotOpen,
{
releaseDate: intl.formatDate(releaseDate, {
day: 'numeric',
month: 'short',
year: 'numeric',
}),
},
)}
</Button>
);
} else if (onboardingPastDue) {
onboardingExamButton = (
<Button variant="secondary" block disabled aria-disabled="true">
{intl.formatMessage(messages.proctoringOnboardingButtonPastDue)}
</Button>
);
} else if (!isNotYetReleased(releaseDate)) {
if (readableStatus === readableStatuses.otherCourseApproved) {
onboardingExamButton = (
<Button variant="primary" block href={link}>
{intl.formatMessage(messages.proctoringOnboardingPracticeButton)}
</Button>
);
} else if (readableStatus !== readableStatuses.otherCourseApproved) {
onboardingExamButton = (
<Button variant="primary" block href={link}>
{intl.formatMessage(messages.proctoringOnboardingButton)}
</Button>
);
}
}
return (
<>
{ link && (
{ showInfoPanel && (
<section className={`mb-4 p-3 outline-sidebar-proctoring-panel ${getBorderClass()}`}>
<h2 className="h4" id="outline-sidebar-upgrade-header">{intl.formatMessage(messages.proctoringInfoPanel)}</h2>
<div>
@@ -114,50 +160,17 @@ function ProctoringInfoPanel({ courseId, username, intl }) {
<>
<p>
{isNotYetSubmitted(status) && (
<>
{intl.formatMessage(messages.proctoringPanelGeneralInfo)}
</>
intl.formatMessage(messages.proctoringPanelGeneralInfo)
)}
{!isNotYetSubmitted(status) && (
<>
{intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)}
</>
intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)
)}
</p>
<p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p>
</>
)}
{isNotYetSubmitted(status) && (
<>
{!isNotYetReleased(releaseDate) && (
<Button variant="primary" block href={link}>
{readableStatus === readableStatuses.otherCourseApproved && (
<>
{intl.formatMessage(messages.proctoringOnboardingPracticeButton)}
</>
)}
{readableStatus !== readableStatuses.otherCourseApproved && (
<>
{intl.formatMessage(messages.proctoringOnboardingButton)}
</>
)}
</Button>
)}
{isNotYetReleased(releaseDate) && (
<Button variant="secondary" block disabled aria-disabled="true">
{intl.formatMessage(
messages.proctoringOnboardingButtonNotOpen,
{
releaseDate: intl.formatDate(releaseDate, {
day: 'numeric',
month: 'short',
year: 'numeric',
}),
},
)}
</Button>
)}
</>
onboardingExamButton
)}
<Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams">
{intl.formatMessage(messages.proctoringReviewRequirementsButton)}

View File

@@ -12,6 +12,7 @@ import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
import * as thunks from '../data/thunks';
import initializeStore from '../../store';
import ProgressTab from './ProgressTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
@@ -20,9 +21,10 @@ describe('Progress Tab', () => {
let axiosMock;
const courseId = 'course-v1:edX+Test+run';
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
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/v1/progress/*`);
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 });
@@ -49,6 +51,7 @@ describe('Progress Tab', () => {
// Set defaults for network requests
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
logUnhandledRequests(axiosMock);
});
@@ -1186,6 +1189,64 @@ describe('Progress Tab', () => {
});
});
describe('Access expiration masquerade banner', () => {
it('renders banner when masquerading as a user', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: true,
},
});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
});
it('does not render banner when not masquerading', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: false,
},
});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
expect(screen.queryByText('1/1/2020')).not.toBeInTheDocument();
});
});
describe('Course start masquerade banner', () => {
it('renders banner when masquerading as a user', async () => {
setMetadata({
is_enrolled: true,
original_user_is_staff: true,
is_staff: false,
start: '2999-01-01T00:00:00Z',
});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
expect(screen.getByText('This learner does not yet have access to this course. The course starts on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2999')).toBeInTheDocument();
});
it('does not render banner when not masquerading', async () => {
setMetadata({
is_enrolled: true,
original_user_is_staff: true,
is_staff: true,
start: '2999-01-01T00:00:00Z',
});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.queryByText('This learner does not yet have access to this course. The course starts on', { exact: false })).not.toBeInTheDocument();
expect(screen.queryByText('1/1/2999')).not.toBeInTheDocument();
});
});
describe('Viewing progress page of other students by changing url', () => {
it('Changing the url changes the header', async () => {
setMetadata({ is_enrolled: true });

View File

@@ -130,7 +130,7 @@ function CertificateStatus({ intl }) {
<FormattedMessage
id="progress.certificateStatus.downloadableBody"
defaultMessage="
Showcase your accomplishment on LinkedIn or your resume today.
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}."
values={{ dashboardLink, profileLink }}

View File

@@ -47,7 +47,7 @@ const messages = defineMessages({
},
downloadableBody: {
id: 'progress.certificateStatus.downloadableBody',
defaultMessage: 'Showcase your accomplishment on LinkedIn or your resume today. You can download your certificate now and access it any time from your Dashboard and Profile.',
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.',
},
downloadableButton: {
id: 'progress.certificateStatus.downloadableButton',

View File

@@ -51,7 +51,7 @@ function DetailedGradesTable({ intl }) {
Header: `${intl.formatMessage(messages.score)}`,
accessor: 'score',
headerClassName: 'justify-content-end h5 mb-0',
cellClassName: 'float-right text-right small',
cellClassName: 'align-top text-right small',
},
]}
>

View File

@@ -52,7 +52,17 @@ function SubsectionTitleCell({ intl, subsection }) {
<Collapsible.Visible whenOpen><Icon src={ArrowDropUp} /></Collapsible.Visible>
</Collapsible.Trigger>
<span className="small d-inline ml-4 pl-1">
{gradesFeatureIsFullyLocked || subsection.learnerHasAccess ? '' : <Icon id={`detailedGradesBlockedIcon${subsection.blockKey}`} aria-label={intl.formatMessage(messages.noAcessToSubsection, { displayName })} className="mr-1 mt-1 d-inline-flex" style={{ height: '1rem', width: '1rem' }} src={Blocked} data-testid="blocked-icon" />}
{gradesFeatureIsFullyLocked || subsection.learnerHasAccess ? ''
: (
<Icon
id={`detailedGradesBlockedIcon${subsection.blockKey}`}
aria-label={intl.formatMessage(messages.noAccessToSubsection, { displayName })}
className="mr-1 mt-1 d-inline-flex"
style={{ height: '1rem', width: '1rem' }}
src={Blocked}
data-testid="blocked-icon"
/>
)}
{url ? (
<a
href={url}
@@ -68,7 +78,7 @@ function SubsectionTitleCell({ intl, subsection }) {
)}
</span>
</Row>
<Collapsible.Body>
<Collapsible.Body className="d-flex w-100">
<ProblemScoreDrawer problemScores={problemScores} subsection={subsection} />
</Collapsible.Body>
</Collapsible.Advanced>

View File

@@ -18,25 +18,28 @@ function AssignmentTypeCell({
gradesFeatureIsFullyLocked,
} = useModel('progress', courseId);
const lockedIcon = locked ? <Icon id={`assignmentTypeBlockedIcon${assignmentType}`} aria-label={intl.formatMessage(messages.noAcessToAssignmentType, { assignmentType })} className="mr-1 mt-1 d-inline-flex" style={{ height: '1rem', width: '1rem' }} src={Blocked} data-testid="blocked-icon" /> : '';
const lockedIcon = locked ? <Icon id={`assignmentTypeBlockedIcon${assignmentType}`} aria-label={intl.formatMessage(messages.noAccessToAssignmentType, { assignmentType })} className="mr-1 mt-1 d-inline-flex" style={{ height: '1rem', width: '1rem' }} src={Blocked} data-testid="blocked-icon" /> : '';
return (
<div className="small">
<span className="d-inline-flex">{lockedIcon}{assignmentType}</span>
{footnoteId && footnoteMarker && (
<sup>
<a
id={`${footnoteId}-ref`}
className="muted-link"
href={`#${footnoteId}-footnote`}
aria-describedby="grade-summary-footnote-label"
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
aria-labelledby={`assignmentTypeBlockedIcon${assignmentType}`}
>
{footnoteMarker}
</a>
</sup>
)}
<div className="d-flex small">
<div className="d-flex">{lockedIcon}</div>
<div>
{assignmentType}&nbsp;
{footnoteId && footnoteMarker && (
<sup>
<a
id={`${footnoteId}-ref`}
className="muted-link"
href={`#${footnoteId}-footnote`}
aria-describedby="grade-summary-footnote-label"
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
aria-labelledby={`assignmentTypeBlockedIcon${assignmentType}`}
>
{footnoteMarker}
</a>
</sup>
)}
</div>
</div>
);
}

View File

@@ -104,7 +104,7 @@ function GradeSummaryTable({ intl, setAllOfSomeAssignmentTypeIsLocked }) {
Cell: ({ value }) => (
<span className={value.locked ? 'greyed-out' : ''}>{value.weight}</span> // eslint-disable-line react/prop-types
),
cellClassName: 'float-right small',
cellClassName: 'text-right small',
},
{
Header: `${intl.formatMessage(messages.grade)}`,
@@ -114,7 +114,7 @@ function GradeSummaryTable({ intl, setAllOfSomeAssignmentTypeIsLocked }) {
Cell: ({ value }) => (
<span className={value.locked ? 'greyed-out' : ''}>{value.grade}</span> // eslint-disable-line react/prop-types
),
cellClassName: 'float-right small',
cellClassName: 'text-right small',
},
{
Header: `${intl.formatMessage(messages.weightedGrade)}`,
@@ -124,7 +124,7 @@ function GradeSummaryTable({ intl, setAllOfSomeAssignmentTypeIsLocked }) {
Cell: ({ value }) => (
<span className={value.locked ? 'greyed-out' : ''}>{value.weightedGrade}</span> // eslint-disable-line react/prop-types
),
cellClassName: 'float-right font-weight-bold small',
cellClassName: 'text-right font-weight-bold small',
},
]}
>

View File

@@ -139,11 +139,11 @@ const messages = defineMessages({
id: 'progress.weightedGradeSummary',
defaultMessage: 'Your current weighted grade summary',
},
noAcessToAssignmentType: {
noAccessToAssignmentType: {
id: 'progress.noAcessToAssignmentType',
defaultMessage: 'You do not have access to assignments of type {assignmentType}',
},
noAcessToSubsection: {
noAccessToSubsection: {
id: 'progress.noAcessToSubsection',
defaultMessage: 'You do not have access to subsection {displayName}',
},

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

@@ -57,16 +57,6 @@ const checkUnitToSequenceUnitRedirect = memoize((courseStatus, courseId, sequenc
}
});
const checkSpecialExamRedirect = memoize((sequenceStatus, sequence, specialExamsEnabled, proctoredExamsEnabled) => {
if (sequenceStatus === 'loaded') {
const shouldRedirectTimeLimited = sequence.isTimeLimited && !specialExamsEnabled;
const shouldRedirectProctored = sequence.isProctored && !proctoredExamsEnabled;
if ((shouldRedirectTimeLimited || shouldRedirectProctored) && sequence.legacyWebUrl !== undefined) {
global.location.assign(sequence.legacyWebUrl);
}
}
});
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
if (sequenceStatus === 'loaded' && sequence.id && !unitId) {
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
@@ -121,8 +111,6 @@ class CoursewareContainer extends Component {
sequenceId,
courseStatus,
sequenceStatus,
specialExamsEnabledWaffleFlag,
proctoredExamsEnabledWaffleFlag,
sequence,
firstSequenceId,
unitViaSequenceId,
@@ -175,11 +163,6 @@ class CoursewareContainer extends Component {
// by filling in the ID of the parent sequence of :unitId.
checkUnitToSequenceUnitRedirect(courseStatus, courseId, sequenceStatus, unitViaSequenceId);
// Check special exam redirect:
// /course/:courseId/:sequenceId(/:unitId) -> :legacyWebUrl
// because special exams are currently still served in the legacy LMS frontend.
checkSpecialExamRedirect(sequenceStatus, sequence, specialExamsEnabledWaffleFlag, proctoredExamsEnabledWaffleFlag);
// Check to 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
@@ -324,8 +307,6 @@ CoursewareContainer.propTypes = {
checkBlockCompletion: PropTypes.func.isRequired,
fetchCourse: PropTypes.func.isRequired,
fetchSequence: PropTypes.func.isRequired,
specialExamsEnabledWaffleFlag: PropTypes.bool.isRequired,
proctoredExamsEnabledWaffleFlag: PropTypes.bool.isRequired,
};
CoursewareContainer.defaultProps = {
@@ -429,8 +410,6 @@ const mapStateToProps = (state) => {
sequenceId,
courseStatus,
sequenceStatus,
specialExamsEnabledWaffleFlag,
proctoredExamsEnabledWaffleFlag,
} = state.courseware;
return {
@@ -438,8 +417,6 @@ const mapStateToProps = (state) => {
sequenceId,
courseStatus,
sequenceStatus,
specialExamsEnabledWaffleFlag,
proctoredExamsEnabledWaffleFlag,
course: currentCourseSelector(state),
sequence: currentSequenceSelector(state),
previousSequence: previousSequenceSelector(state),

View File

@@ -159,7 +159,7 @@ describe('CoursewareContainer', () => {
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);
@@ -397,8 +397,6 @@ describe('CoursewareContainer', () => {
describe('when the current sequence is an exam', () => {
const { location } = window;
const sequenceBlock = defaultSequenceBlock;
const unitBlocks = defaultUnitBlocks;
beforeEach(() => {
delete window.location;
@@ -410,20 +408,6 @@ describe('CoursewareContainer', () => {
afterEach(() => {
window.location = location;
});
it('should redirect to the sequence legacyWebUrl', async () => {
const sequenceMetadata = Factory.build(
'sequenceMetadata',
{ is_time_limited: true }, // position index is 1-based and is converted to 0-based for activeUnitIndex
{ courseId, unitBlocks, sequenceBlock },
);
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
await loadContainer();
expect(global.location.assign).toHaveBeenCalledWith(sequenceBlock.legacy_web_url);
});
});
});

View File

@@ -15,6 +15,7 @@ import NotificationTrigger from './NotificationTrigger';
import { useModel } from '../../generic/model-store';
import useWindowSize, { responsiveBreakpoints } from '../../generic/tabs/useWindowSize';
import { getLocalStorage, setLocalStorage } from '../../data/localStorage';
/** [MM-P2P] Experiment */
import { initCoursewareMMP2P, MMP2PBlockModal } from '../../experiments/mm-p2p';
@@ -60,6 +61,22 @@ function Course({
if (notificationTrayVisible) { setNotificationTray(false); } else { setNotificationTray(true); }
};
if (!getLocalStorage('notificationStatus')) {
setLocalStorage('notificationStatus', 'active'); // Show red dot on notificationTrigger until seen
}
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);
@@ -73,6 +90,8 @@ function Course({
courseId={courseId}
sectionId={section ? section.id : null}
sequenceId={sequenceId}
isStaff={course ? course.isStaff : null}
unitId={unitId}
//* * [MM-P2P] Experiment */
mmp2p={MMP2P}
/>
@@ -81,6 +100,9 @@ function Course({
<NotificationTrigger
toggleNotificationTray={toggleNotificationTray}
isNotificationTrayVisible={isNotificationTrayVisible}
notificationStatus={notificationStatus}
setNotificationStatus={setNotificationStatus}
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
/>
) : null}
</div>
@@ -96,6 +118,11 @@ function Course({
toggleNotificationTray={toggleNotificationTray}
isNotificationTrayVisible={isNotificationTrayVisible}
notificationTrayVisible={notificationTrayVisible}
notificationStatus={notificationStatus}
setNotificationStatus={setNotificationStatus}
onNotificationSeen={onNotificationSeen}
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
//* * [MM-P2P] Experiment */
mmp2p={MMP2P}
/>

View File

@@ -91,6 +91,31 @@ describe('Course', () => {
expect(notificationTrigger).not.toHaveClass('trigger-active');
});
it('renders course breadcrumbs as expected', async () => {
const courseMetadata = Factory.build('courseMetadata');
const unitBlocks = Array.from({ length: 3 }).map(() => Factory.build(
'block',
{ type: 'vertical' },
{ courseId: courseMetadata.id },
));
const testStore = await initializeTestStore({ courseMetadata, unitBlocks }, false);
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests.
};
render(<Course {...testData} />, { store: testStore });
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();
});
it('passes handlers to the sequence', async () => {
const nextSequenceHandler = jest.fn();
const previousSequenceHandler = jest.fn();

View File

@@ -5,91 +5,151 @@ 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 { useModel } from '../../generic/model-store';
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({
url, children, withSeparator, ...attrs
content, withSeparator, courseId, unitId, isStaff,
}) {
const defaultContent = content.filter(destination => destination.default)[0] || { id: courseId, label: '' };
return (
<>
{withSeparator && (
<li className="mx-2 text-primary-500" role="presentation" aria-hidden>/</li>
<li className="mx-2 text-primary-500 text-truncate text-nowrap" role="presentation" aria-hidden>/</li>
)}
<li {...attrs}>
<a className="text-primary-500" href={url}>{children}</a>
<li style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{ getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff
? (
<Link
className="text-primary-500"
to={`/course/${courseId}/${defaultContent.id}`}
>
{defaultContent.label}
</Link>
)
: (
<SelectMenu isLink defaultMessage={defaultContent.label}>
{content.map(item => (
<JumpNavMenuItem
isDefault={item.default}
sequences={item.sequences}
courseId={courseId}
title={item.label}
currentUnit={unitId}
/>
))}
</SelectMenu>
)}
</li>
</>
);
}
CourseBreadcrumb.propTypes = {
url: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
content: PropTypes.arrayOf(
PropTypes.shape({
default: PropTypes.bool,
id: PropTypes.string,
label: PropTypes.string,
}),
).isRequired,
unitId: PropTypes.string,
withSeparator: PropTypes.bool,
courseId: PropTypes.string,
isStaff: PropTypes.bool,
};
CourseBreadcrumb.defaultProps = {
withSeparator: false,
unitId: null,
courseId: null,
isStaff: null,
};
export default function CourseBreadcrumbs({
courseId,
sectionId,
sequenceId,
unitId,
isStaff,
/** [MM-P2P] Experiment */
mmp2p,
}) {
const course = useModel('coursewareMeta', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sectionId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const allSequencesInSections = Object.fromEntries(useModels('sections', course.sectionIds).map(section => [section.id, {
default: section.id === sectionId,
title: section.title,
sequences: useModels('sequences', section.sequenceIds),
}]));
const links = useMemo(() => {
const chapters = [];
const sequentials = [];
if (courseStatus === 'loaded' && sequenceStatus === 'loaded') {
return [section, sequence].filter(node => !!node).map((node) => ({
id: node.id,
label: node.title,
url: `${getConfig().LMS_BASE_URL}/courses/${course.id}/course/#${node.id}`,
}));
Object.entries(allSequencesInSections).forEach(([id, section]) => {
chapters.push({
id,
label: section.title,
default: section.default,
sequences: section.sequences,
});
if (section.default) {
section.sequences.forEach(sequence => {
sequentials.push({
id: sequence.id,
label: sequence.title,
default: sequence.id === sequenceId,
sequences: [sequence],
});
});
}
});
}
return [];
}, [courseStatus, sequenceStatus]);
return [chapters, sequentials];
}, [courseStatus, sequenceStatus, allSequencesInSections]);
return (
<nav aria-label="breadcrumb" className="my-4 d-inline-block col-sm-10">
<ol className="list-unstyled d-flex m-0">
<CourseBreadcrumb
url={`${getConfig().LMS_BASE_URL}/courses/${course.id}/course/`}
className="flex-shrink-0"
>
<FontAwesomeIcon icon={faHome} className="mr-2" />
<FormattedMessage
id="learn.breadcrumb.navigation.course.home"
description="The course home link in breadcrumbs nav"
defaultMessage="Course"
/>
</CourseBreadcrumb>
{links.map(({ id, url, label }) => (
<CourseBreadcrumb
key={id}
url={url}
withSeparator
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
<ol className="list-unstyled d-flex flex-nowrap align-items-center m-0">
<li className="list-unstyled d-flex m-0">
<Link
className="flex-shrink-0 text-primary"
to={`/course/${courseId}/home`}
>
{label}
</CourseBreadcrumb>
<FontAwesomeIcon icon={faHome} className="mr-2" />
<FormattedMessage
id="learn.breadcrumb.navigation.course.home"
description="The course home link in breadcrumbs nav"
defaultMessage="Course"
/>
</Link>
</li>
{links.map(content => (
<CourseBreadcrumb
courseId={courseId}
sequenceId={sequenceId}
content={content}
unitId={unitId}
withSeparator
isStaff={isStaff}
/>
))}
{/** [MM-P2P] Experiment */}
{mmp2p.state.isEnabled && (
{mmp2p.state && mmp2p.state.isEnabled && (
<MMP2PFlyoverTrigger options={mmp2p} />
)}
</ol>
@@ -101,6 +161,8 @@ CourseBreadcrumbs.propTypes = {
courseId: PropTypes.string.isRequired,
sectionId: PropTypes.string,
sequenceId: PropTypes.string,
unitId: PropTypes.string,
isStaff: PropTypes.bool,
/** [MM-P2P] Experiment */
mmp2p: PropTypes.shape({
state: PropTypes.shape({
@@ -112,7 +174,8 @@ CourseBreadcrumbs.propTypes = {
CourseBreadcrumbs.defaultProps = {
sectionId: null,
sequenceId: null,
unitId: null,
isStaff: null,
/** [MM-P2P] Experiment */
mmp2p: {},
};

View File

@@ -0,0 +1,125 @@
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';
jest.mock('@edx/frontend-platform');
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
jest.mock('react-redux', () => ({
connect: (mapStateToProps, mapDispatchToProps) => (ReactComponent) => ({
mapStateToProps,
mapDispatchToProps,
ReactComponent,
}),
Provider: ({ children }) => children,
useSelector: () => 'loaded',
}));
useModels.mockImplementation((name) => {
if (name === 'sections') {
return [
{
courseId: 'course-v1:edX+DemoX+Demo_Course',
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b',
sequenceIds: ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction'],
title: 'Introduction',
},
{
courseId: 'course-v1:edX+DemoX+Demo_Course',
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
sequenceIds: ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions'],
title: 'Example Week 1: Getting Started',
},
];
}
return [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5',
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
title: 'Lesson 1 - Getting Started',
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',
],
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
title: 'Homework - Question Styles',
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',
],
},
];
});
useModel.mockImplementation(() => ({
sectionIds: ['block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b',
'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations'],
}));
describe('CourseBreadcrumbs', () => {
jest.spyOn(React, 'useMemo').mockImplementation(() => [
[
{
default: false,
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b',
label: 'Introduction',
url: 'http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction',
},
{
default: true,
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
label: 'Example Week 1: Getting Started',
url: 'http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course/block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5',
},
],
[
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations', label: "Lesson 2 - Let's Get Interactive!", default: true, url: 'http://localhost:2000/course/course-v1:edX+DemoX+D…e@vertical+block@d0d804e8863c4a95a659c04d8a2b2bc0',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e', label: 'Homework - Essays', default: false, url: 'http://localhost:2000/course/course-v1:edX+DemoX+D…e@vertical+block@fb79dcbad35b466a8c6364f8ffee9050',
},
],
]);
render(
<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);
const courseHomeButtonDestination = screen.getAllByRole('link')[0].href;
expect(courseHomeButtonDestination).toBe('http://localhost/course/course-v1:edX+DemoX+Demo_Course/home');
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(screen.queryAllByRole('button')).toHaveLength(2);
});
});

View File

@@ -0,0 +1,80 @@
/* eslint-disable consistent-return */
import React from 'react';
import PropTypes from 'prop-types';
import { history } from '@edx/frontend-platform';
import { MenuItem } from '@edx/paragon';
import {
sendTrackingLogEvent,
sendTrackEvent,
} from '@edx/frontend-platform/analytics';
import { useDispatch } from 'react-redux';
import { checkBlockCompletion } from '../data';
export default function JumpNavMenuItem({
title,
courseId,
currentUnit,
sequences,
isDefault,
}) {
const dispatch = useDispatch();
function logEvent(targetUrl) {
const eventName = 'edx.ui.lms.jump_nav.selected';
const payload = {
target_name: title,
id: targetUrl,
current_id: courseId,
widget_placement: 'breadcrumb',
};
sendTrackEvent(eventName, payload);
sendTrackingLogEvent(eventName, payload);
}
function lazyloadUrl() {
if (isDefault) {
return `/course/${courseId}/${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]}`;
}
function handleClick() {
const url = lazyloadUrl();
logEvent(url);
history.push(url);
}
return (
<MenuItem
defaultSelected={isDefault}
onClick={() => handleClick()}
>
{title}
</MenuItem>
);
}
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 = {
title: PropTypes.string.isRequired,
sequences: PropTypes.arrayOf(sequenceShape).isRequired,
isDefault: PropTypes.bool.isRequired,
courseId: PropTypes.string.isRequired,
currentUnit: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { screen, render } from '@testing-library/react';
import JumpNavMenuItem from './JumpNavMenuItem';
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',
title: 'Demo Menu Item',
courseId: 'course-v1:edX+DemoX+Demo_Course',
currentUnit: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
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,
};
describe('JumpNavMenuItem', () => {
render(
<JumpNavMenuItem
{...mockData}
/>,
);
it('renders menu Item as expected with button and Text and handles clicks', () => {
expect(screen.queryAllByRole('button')).toHaveLength(1);
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.
});
});

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import classNames from 'classnames';
@@ -12,7 +12,7 @@ import useWindowSize, { responsiveBreakpoints } from '../../generic/tabs/useWind
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
function NotificationTray({
intl, toggleNotificationTray,
intl, toggleNotificationTray, onNotificationSeen, upgradeNotificationCurrentState, setupgradeNotificationCurrentState,
}) {
const {
courseId,
@@ -32,6 +32,9 @@ function NotificationTray({
const shouldDisplayFullScreen = useWindowSize().width < responsiveBreakpoints.large.minWidth;
// After three seconds, update notificationSeen (to hide red dot)
useEffect(() => { setTimeout(onNotificationSeen, 3000); }, []);
return (
<section className={classNames('notification-tray-container ml-0 ml-lg-4', { 'no-notification': !verifiedMode && !shouldDisplayFullScreen })} aria-label={intl.formatMessage(messages.notificationTray)}>
{shouldDisplayFullScreen ? (
@@ -64,6 +67,8 @@ function NotificationTray({
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
/>
) : <p className="notification-tray-content">{intl.formatMessage(messages.noNotificationsMessage)}</p>}
</div>
@@ -74,10 +79,14 @@ function NotificationTray({
NotificationTray.propTypes = {
intl: intlShape.isRequired,
toggleNotificationTray: PropTypes.func,
onNotificationSeen: PropTypes.func,
upgradeNotificationCurrentState: PropTypes.string.isRequired,
setupgradeNotificationCurrentState: PropTypes.func.isRequired,
};
NotificationTray.defaultProps = {
toggleNotificationTray: null,
onNotificationSeen: null,
};
export default injectIntl(NotificationTray);

View File

@@ -1,12 +1,31 @@
import React from 'react';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getLocalStorage, setLocalStorage } from '../../data/localStorage';
import NotificationIcon from './NotificationIcon';
import messages from './messages';
function NotificationTrigger({ intl, toggleNotificationTray, isNotificationTrayVisible }) {
function NotificationTrigger({
intl, toggleNotificationTray, isNotificationTrayVisible, notificationStatus, setNotificationStatus,
upgradeNotificationCurrentState,
}) {
/* Re-show a red dot beside the notification trigger for each of the 7 UpgradeNotification stages
The upgradeNotificationCurrentState prop will be available after UpgradeNotification mounts. Once available,
compare with the last state they've seen, and if it's different then set dot back to red */
function UpdateUpgradeNotificationLastSeen() {
if (upgradeNotificationCurrentState) {
if (getLocalStorage('upgradeNotificationLastSeen') !== upgradeNotificationCurrentState) {
setNotificationStatus('active');
setLocalStorage('notificationStatus', 'active');
setLocalStorage('upgradeNotificationLastSeen', upgradeNotificationCurrentState);
}
}
}
useEffect(() => { UpdateUpgradeNotificationLastSeen(); });
return (
<button
className={classNames('notification-trigger-btn', { 'trigger-active': isNotificationTrayVisible() })}
@@ -14,8 +33,7 @@ function NotificationTrigger({ intl, toggleNotificationTray, isNotificationTrayV
onClick={() => { toggleNotificationTray(); }}
aria-label={intl.formatMessage(messages.openNotificationTrigger)}
>
{/* REV-2297 TODO: add logic for status "active" if red dot should display */}
<NotificationIcon status="inactive" notificationColor="bg-danger-500" />
<NotificationIcon status={notificationStatus} notificationColor="bg-danger-500" />
</button>
);
}
@@ -23,7 +41,10 @@ function NotificationTrigger({ intl, toggleNotificationTray, isNotificationTrayV
NotificationTrigger.propTypes = {
intl: intlShape.isRequired,
toggleNotificationTray: PropTypes.func.isRequired,
notificationStatus: PropTypes.string.isRequired,
setNotificationStatus: PropTypes.func.isRequired,
isNotificationTrayVisible: PropTypes.func.isRequired,
upgradeNotificationCurrentState: PropTypes.string.isRequired,
};
export default injectIntl(NotificationTrigger);

View File

@@ -1,7 +1,7 @@
.notification-trigger-btn {
border: 1px solid $light-400;
background: none;
margin-top: 0.625rem;
margin-top: 1rem;
position: absolute;
right: 0;

View File

@@ -4,27 +4,51 @@ import {
render, initializeTestStore, screen, fireEvent,
} from '../../setupTest';
import NotificationTrigger from './NotificationTrigger';
import { getLocalStorage } from '../../data/localStorage';
describe('Notification Trigger', () => {
let mockData;
// let mockDataSameState;
// let mockDataDifferentState;
const courseMetadata = Factory.build('courseMetadata');
beforeAll(async () => {
beforeEach(async () => {
await initializeTestStore({ courseMetadata, excludeFetchCourse: true, excludeFetchSequence: true });
mockData = {
toggleNotificationTray: () => {},
isNotificationTrayVisible: () => {},
notificationStatus: 'active',
setNotificationStatus: () => {},
upgradeNotificationCurrentState: 'FPDdaysLeft',
};
});
it('renders notification trigger with icon', async () => {
it('renders notification trigger icon with red dot when notificationStatus is active', async () => {
const { container } = render(<NotificationTrigger {...mockData} />);
expect(container).toBeInTheDocument();
const buttonIcon = container.querySelectorAll('svg');
expect(buttonIcon).toHaveLength(1);
expect(screen.getByTestId('notification-dot')).toBeInTheDocument();
});
// REV-2297 TODO: update below test once the status=active or inactive is implemented
// expect(screen.getByTestId('notification-dot')).toBeInTheDocument();
it('renders notification trigger icon WITHOUT red dot 3 seconds later', async () => {
const { container } = render(<NotificationTrigger {...mockData} />);
expect(container).toBeInTheDocument();
jest.useFakeTimers();
setTimeout(() => {
expect(screen.queryByRole('notification-dot')).not.toBeInTheDocument();
}, 3000);
jest.runAllTimers();
});
it('renders notification trigger icon WITHOUT red dot within the same phase', async () => {
const { container } = render(
<NotificationTrigger {...mockData} upgradeNotificationCurrentState="sameState" upgradeNotificationLastSeen="sameState" />,
);
expect(container).toBeInTheDocument();
const buttonIcon = container.querySelectorAll('svg');
expect(buttonIcon).toHaveLength(1);
expect(screen.queryByRole('notification-dot')).not.toBeInTheDocument();
});
it('handles onClick event toggling the notification tray', async () => {
@@ -40,4 +64,16 @@ describe('Notification Trigger', () => {
fireEvent.click(notificationTrigger);
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
});
// rendering NotificationTrigger has the effect of calling UpdateUpgradeNotificationLastSeen()
// Verify that local storage was updated accordingly
it('we make the right updates when rendering a new phase (before -> after)', async () => {
const { container } = render(
<NotificationTrigger {...mockData} upgradeNotificationLastSeen="before" upgradeNotificationCurrentState="after" />,
);
expect(container).toBeInTheDocument();
expect(getLocalStorage('notificationStatus')).toBe('active');
expect(getLocalStorage('upgradeNotificationLastSeen')).toBe('after');
});
});

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -11,6 +11,7 @@ import CourseInProgress from './CourseInProgress';
import CourseNonPassing from './CourseNonPassing';
import { COURSE_EXIT_MODES, getCourseExitMode } from './utils';
import messages from './messages';
import { unsubscribeFromGoalReminders } from './data/thunks';
import { useModel } from '../../../generic/model-store';
@@ -18,10 +19,13 @@ function CourseExit({ intl }) {
const { courseId } = useSelector(state => state.courseware);
const {
certificateData,
courseExitPageIsActive,
courseGoals,
enrollmentMode,
hasScheduledContent,
isEnrolled,
isMasquerading,
userHasPassingGrade,
courseExitPageIsActive,
} = useModel('coursewareMeta', courseId);
const mode = getCourseExitMode(
@@ -32,6 +36,15 @@ function CourseExit({ intl }) {
courseExitPageIsActive,
);
// Audit users cannot fully complete a course, so we will
// unsubscribe them from goal reminders once they reach the course exit page
// to avoid spamming them with goal reminder emails
if (courseGoals && enrollmentMode === 'audit' && !isMasquerading) {
useEffect(() => {
unsubscribeFromGoalReminders(courseId);
}, []);
}
let body = null;
if (mode === COURSE_EXIT_MODES.nonPassing) {
body = (<CourseNonPassing />);

View File

@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { waitFor } from '@testing-library/react';
import { fetchCourse } from '../../data';
import { buildSimpleCourseBlocks } from '../../../shared/data/__factories__/courseBlocks.factory';
@@ -377,4 +378,25 @@ describe('Course Exit Pages', () => {
expect(screen.getByRole('link', { name: 'View course schedule' })).toBeInTheDocument();
});
});
it('unsubscribes the user when loading the course exit page', async () => {
setMetadata({
enrollment: {
mode: 'audit',
courseGoals: {
goal_options: [],
selected_goal: {
days_per_week: 1,
subscribed_to_reminders: true,
},
},
},
});
await fetchAndRender(<CourseExit />);
const url = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
await waitFor(() => {
expect(axiosMock.history.post[0].url).toMatch(url);
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${defaultMetadata.id}","subscribed_to_reminders":false}`);
});
});
});

View File

@@ -23,7 +23,7 @@ function filterRecommendationsList(
));
}
export default async function getCourseRecommendations(courseKey) {
export async function getCourseRecommendations(courseKey) {
const discoveryApiUrl = getConfig().DISCOVERY_API_BASE_URL;
if (!discoveryApiUrl) {
return [];
@@ -36,3 +36,11 @@ export default async function getCourseRecommendations(courseKey) {
]);
return filterRecommendationsList(camelCaseObject(recommendationsResponse), camelCaseObject(enrollmentsResponse));
}
export async function postUnsubscribeFromGoalReminders(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`);
return getAuthenticatedHttpClient().post(url.href, {
course_id: courseId,
subscribed_to_reminders: false,
});
}

View File

@@ -5,7 +5,10 @@ import {
fetchCourseRecommendationsRequest,
fetchCourseRecommendationsSuccess,
} from './slice';
import getCourseRecommendations from './api';
import {
getCourseRecommendations,
postUnsubscribeFromGoalReminders,
} from './api';
import { updateModel } from '../../../../generic/model-store';
export default function fetchCourseRecommendations(courseKey, courseId) {
@@ -27,3 +30,7 @@ export default function fetchCourseRecommendations(courseKey, courseId) {
}
};
}
export async function unsubscribeFromGoalReminders(courseId, daysPerWeek, subscribedToReminders) {
return postUnsubscribeFromGoalReminders(courseId, daysPerWeek, subscribedToReminders);
}

View File

@@ -20,6 +20,7 @@ import { useModel } from '../../../generic/model-store';
import CourseLicense from '../course-license';
import messages from './messages';
import HiddenAfterDue from './hidden-after-due';
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
import SequenceContent from './SequenceContent';
import NotificationTray from '../NotificationTray';
@@ -40,14 +41,17 @@ function Sequence({
toggleNotificationTray,
notificationTrayVisible,
isNotificationTrayVisible,
notificationStatus,
setNotificationStatus,
onNotificationSeen,
upgradeNotificationCurrentState,
setupgradeNotificationCurrentState,
mmp2p,
}) {
const course = useModel('coursewareMeta', courseId);
const sequence = useModel('sequences', sequenceId);
const unit = useModel('units', unitId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const specialExamsEnabledWaffleFlag = useSelector(state => state.courseware.specialExamsEnabledWaffleFlag);
const proctoredExamsEnabledWaffleFlag = useSelector(state => state.courseware.proctoredExamsEnabledWaffleFlag);
const shouldDisplayNotificationTrigger = useWindowSize().width < responsiveBreakpoints.small.minWidth;
const handleNext = () => {
@@ -139,24 +143,10 @@ function Sequence({
);
}
/*
TODO: When the micro-frontend supports viewing special exams without redirecting to the legacy
experience, we can remove this whole conditional. For now, though, we show the spinner here
because we expect CoursewareContainer to be performing a redirect to the legacy experience while
we're waiting. That redirect may take a few seconds, so we show the spinner in the meantime.
*/
if (sequenceStatus === 'loaded') {
const shouldRedirectSpecialExams = sequence.isTimeLimited && !specialExamsEnabledWaffleFlag;
const shouldRedirectProctoredExams = sequence.isProctored && specialExamsEnabledWaffleFlag
&& !proctoredExamsEnabledWaffleFlag;
if (shouldRedirectSpecialExams || shouldRedirectProctoredExams) {
return (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
/>
);
}
if (sequenceStatus === 'loaded' && sequence.isHiddenAfterDue) {
// Shouldn't even be here - these sequences are normally stripped out of the navigation.
// But we are here, so render a notice instead of the normal content.
return <HiddenAfterDue courseId={courseId} />;
}
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
@@ -194,6 +184,9 @@ function Sequence({
<NotificationTrigger
toggleNotificationTray={toggleNotificationTray}
isNotificationTrayVisible={isNotificationTrayVisible}
notificationStatus={notificationStatus}
setNotificationStatus={setNotificationStatus}
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
/>
) : null}
@@ -229,6 +222,10 @@ function Sequence({
<NotificationTray
toggleNotificationTray={toggleNotificationTray}
notificationTrayVisible={notificationTrayVisible}
notificationStatus={notificationStatus}
onNotificationSeen={onNotificationSeen}
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
/>
) : null }
@@ -244,7 +241,12 @@ function Sequence({
if (sequenceStatus === 'loaded') {
return (
<div>
<SequenceExamWrapper sequence={sequence} courseId={courseId} isStaff={course.isStaff}>
<SequenceExamWrapper
sequence={sequence}
courseId={courseId}
isStaff={course.isStaff}
originalUserIsStaff={course.originalUserIsStaff}
>
{defaultContent}
</SequenceExamWrapper>
<CourseLicense license={course.license || undefined} />
@@ -271,6 +273,11 @@ Sequence.propTypes = {
toggleNotificationTray: PropTypes.func,
notificationTrayVisible: PropTypes.bool,
isNotificationTrayVisible: PropTypes.func,
notificationStatus: PropTypes.string.isRequired,
setNotificationStatus: PropTypes.func.isRequired,
onNotificationSeen: PropTypes.func,
upgradeNotificationCurrentState: PropTypes.string.isRequired,
setupgradeNotificationCurrentState: PropTypes.func.isRequired,
/** [MM-P2P] Experiment */
mmp2p: PropTypes.shape({
@@ -292,6 +299,7 @@ Sequence.defaultProps = {
toggleNotificationTray: null,
notificationTrayVisible: null,
isNotificationTrayVisible: null,
onNotificationSeen: null,
/** [MM-P2P] Experiment */
mmp2p: {

View File

@@ -81,11 +81,7 @@ describe('Sequence', () => {
expect(screen.queryByText('Loading locked content messaging...')).not.toBeInTheDocument();
});
it('renders correctly for exam content', async () => {
// Exams should NOT render in the Sequence. They should permanently show a spinner until the
// application redirects away from the page. Note that this component is not responsible for
// that redirect behavior, so there's no record of it here.
// See CoursewareContainer.jsx "checkExamRedirect" function.
it('renders correctly for hidden after due content', async () => {
const sequenceBlocks = [Factory.build(
'block',
{ type: 'sequential', children: [unitBlocks.map(block => block.id)] },
@@ -93,7 +89,7 @@ describe('Sequence', () => {
)];
const sequenceMetadata = [Factory.build(
'sequenceMetadata',
{ is_time_limited: true },
{ is_hidden_after_due: true },
{ courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlocks[0] },
)];
const testStore = await initializeTestStore(
@@ -101,15 +97,19 @@ describe('Sequence', () => {
courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata,
}, false,
);
const { container } = render(
render(
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
{ store: testStore },
);
// We expect that the sequence container isn't rendering at all.
expect(container.querySelector('.sequence-container')).toBeNull();
// But that we're seeing a nice spinner.
expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByText('The due date for this assignment has passed.')).toBeInTheDocument();
});
expect(screen.getByRole('link', { name: 'progress page' }))
.toHaveAttribute('href', 'http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress');
// No normal content or navigation should be rendered. Just the above alert.
expect(screen.queryAllByRole('button').length).toEqual(0);
});
it('displays error message on sequence load failure', async () => {

View File

@@ -0,0 +1,52 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { useModel } from '../../../../generic/model-store';
import messages from './messages';
function HiddenAfterDue({ courseId, intl }) {
const { tabs } = useModel('coursewareMeta', courseId);
const progressTab = tabs.find(tab => tab.slug === 'progress');
const progressLink = progressTab && progressTab.url && (
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={progressTab.url}
className="text-reset"
>
{intl.formatMessage(messages.progressPage)}
</Hyperlink>
);
return (
<Alert variant="info" icon={Info}>
<h3>{intl.formatMessage(messages.header)}</h3>
<p>
{intl.formatMessage(messages.description)}
{progressLink && (
<>
<br />
<FormattedMessage
id="learn.hiddenAfterDue.gradeAvailable"
defaultMessage="If you have completed this assignment, your grade is available on the {progressPage}."
values={{
progressPage: progressLink,
}}
/>
</>
)}
</p>
</Alert>
);
}
HiddenAfterDue.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default injectIntl(HiddenAfterDue);

View File

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

View File

@@ -0,0 +1,23 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
header: {
id: 'learn.hiddenAfterDue.header',
defaultMessage: 'The due date for this assignment has passed.',
},
description: {
id: 'learn.hiddenAfterDue.description',
defaultMessage: 'Because the due date has passed, this assignment is no longer available.',
},
gradeAvailable: {
id: 'learn.hiddenAfterDue.gradeAvailable',
defaultMessage: 'If you have completed this assignment, your grade is available on the {progressPage}.',
},
progressPage: {
id: 'learn.hiddenAfterDue.progressPage',
defaultMessage: 'progress page',
description: 'This is the text for the link embedded in learn.hiddenAfterDue.gradeAvailable',
},
});
export default messages;

View File

@@ -5,19 +5,19 @@ import { getConfig, history } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ActionRow, Alert, Button } from '@edx/paragon';
import { useModel } from '../../../../generic/model-store';
import { saveIntegritySignature } from '../../../data';
import messages from './messages';
function HonorCode({ intl, courseId }) {
const dispatch = useDispatch();
const { isMasquerading } = useModel('coursewareMeta', courseId);
const siteName = getConfig().SITE_NAME;
const honorCodeUrl = `${process.env.TERMS_OF_SERVICE_URL}#honor-code`;
const honorCodeUrl = `${getConfig().TERMS_OF_SERVICE_URL}#honor-code`;
const handleCancel = () => history.push(`/course/${courseId}/home`);
const handleAgree = () => {
dispatch(saveIntegritySignature(courseId));
};
const handleAgree = () => dispatch(saveIntegritySignature(courseId, isMasquerading));
return (
<Alert variant="light" aria-live="off">

View File

@@ -113,7 +113,7 @@ function LockPaywall({
<span className="fa-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage
id="gatedContent.paragraph.bulletOne"
defaultMessage="Earn a {verifiedCertLink} of completion to showcase on your resume"
defaultMessage="Earn a {verifiedCertLink} of completion to showcase on your resumé"
values={{ verifiedCertLink }}
className="bullet-text"
/>
@@ -149,7 +149,7 @@ function LockPaywall({
<div
className={
classNames('p-md-0 d-md-flex align-items-md-center text-right', {
classNames('d-md-flex align-items-md-center text-right', {
'col-md-5 mx-md-0': notificationTrayVisible, 'col-md-4 mx-md-3 justify-content-center': !notificationTrayVisible && !shouldDisplayGatedContentTwoColumnsHalf, 'col-md-11 justify-content-end': shouldDisplayGatedContentOneColumn && !shouldDisplayGatedContentTwoColumns, 'col-md-6 justify-content-center': shouldDisplayGatedContentTwoColumnsHalf,
})
}

View File

@@ -1,3 +1,7 @@
.alert-content.lock-paywall-container {
display: inline-flex;
}
.lock-paywall-container svg {
color: $primary-700;
}

View File

@@ -8,6 +8,13 @@ Factory.define('courseMetadata')
.attrs({
content_type_gating_enabled: false,
course_expired_message: null,
course_goals: {
goal_options: [],
selected_goal: {
days_per_week: 1,
subscribed_to_reminders: true,
},
},
end: null,
enrollment_start: null,
enrollment_end: null,
@@ -55,7 +62,5 @@ Factory.define('courseMetadata')
linkedin_add_to_profile_url: null,
related_programs: null,
user_needs_integrity_signature: false,
is_mfe_special_exams_enabled: false,
is_mfe_proctored_exams_enabled: false,
recommendations: null,
});

View File

@@ -58,6 +58,7 @@ Factory.define('sequenceMetadata')
save_position: true,
prev_url: null,
is_time_limited: false,
is_hidden_after_due: false,
show_completion: true,
banner_text: null,
format: 'Homework',

View File

@@ -187,6 +187,7 @@ function normalizeMetadata(metadata) {
accessExpiration: camelCaseObject(data.access_expiration),
canShowUpgradeSock: data.can_show_upgrade_sock,
contentTypeGatingEnabled: data.content_type_gating_enabled,
courseGoals: data.course_goals,
id: data.id,
title: data.name,
number: data.number,
@@ -219,8 +220,6 @@ function normalizeMetadata(metadata) {
linkedinAddToProfileUrl: data.linkedin_add_to_profile_url,
relatedPrograms: camelCaseObject(data.related_programs),
userNeedsIntegritySignature: data.user_needs_integrity_signature,
specialExamsEnabledWaffleFlag: data.is_mfe_special_exams_enabled,
proctoredExamsEnabledWaffleFlag: data.is_mfe_proctored_exams_enabled,
isMasquerading: data.original_user_is_staff && !data.is_staff,
};
}
@@ -253,6 +252,7 @@ function normalizeSequenceMetadata(sequence) {
gatedContent: camelCaseObject(sequence.gated_content),
isTimeLimited: sequence.is_time_limited,
isProctored: sequence.is_proctored,
isHiddenAfterDue: sequence.is_hidden_after_due,
// Position comes back from the server 1-indexed. Adjust here.
activeUnitIndex: sequence.position ? sequence.position - 1 : 0,
saveUnitPosition: sequence.save_position,

View File

@@ -4,9 +4,19 @@ import { mergeConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import {
getCourseBlocks, getCourseMetadata, getSequenceMetadata, postSequencePosition, getBlockCompletion,
getCourseBlocks,
getCourseMetadata,
getSequenceMetadata,
postSequencePosition,
getBlockCompletion,
getResumeBlock,
sendActivationEmail,
} from '../api';
import { initializeMockApp } from '../../../setupTest';
import {
courseId, dateRegex, opaqueKeysRegex, sequenceId, usageId,
} from '../../../pacts/constants';
const {
somethingLike: like, term, boolean, string, eachLike, integer,
@@ -15,17 +25,13 @@ const provider = new Pact({
consumer: 'frontend-app-learning',
provider: 'lms',
log: path.resolve(process.cwd(), 'src/courseware/data/pact-tests/logs', 'pact.log'),
dir: path.resolve(process.cwd(), 'src/courseware/data/pact-tests'),
dir: path.resolve(process.cwd(), 'src/pacts'),
pactfileWriteMode: 'merge',
logLevel: 'DEBUG',
cors: true,
});
describe('Courseware Service', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const sequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions';
const usageId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@47dbd5f836544e61877a483c0b75606c';
const dateRegex = '^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$';
const opaqueKeysRegex = '[\\w\\-~.:]';
let authenticatedUser;
beforeAll(async () => {
initializeMockApp();
@@ -57,8 +63,7 @@ describe('Courseware Service', () => {
},
willRespondWith: {
status: 200,
body:
{
body: {
root: string('block-v1:edX+DemoX+Demo_Course+type@course+block@course'),
blocks: like({
'block-v1:edX+DemoX+Demo_Course+type@course+block@course': {
@@ -105,8 +110,7 @@ describe('Courseware Service', () => {
},
willRespondWith: {
status: 200,
body:
{
body: {
access_expiration: {
expiration_date: term({
generate: '2013-02-05T05:00:00Z',
@@ -170,7 +174,10 @@ describe('Courseware Service', () => {
}),
user_timezone: null,
verified_mode: like({
access_expiration_date: null,
access_expiration_date: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
currency: 'USD',
currency_symbol: '$',
price: 149,
@@ -182,7 +189,7 @@ describe('Courseware Service', () => {
can_view_legacy_courseware: boolean(true),
is_staff: boolean(true),
course_access: like({
has_access: boolean(true),
has_access: true,
error_code: null,
developer_message: null,
user_message: null,
@@ -192,9 +199,9 @@ describe('Courseware Service', () => {
notes: { enabled: boolean(false), visible: boolean(true) },
marketing_url: null,
celebrations: {
irst_section: boolean(false),
first_section: boolean(false),
streak_length_to_celebrate: null,
streak_discount_experiment_enabled: boolean(false),
streak_discount_enabled: boolean(false),
},
user_has_passing_grade: boolean(false),
course_exit_page_is_active: boolean(false),
@@ -204,8 +211,6 @@ describe('Courseware Service', () => {
verify_identity_url: null,
verification_status: string('none'),
linkedin_add_to_profile_url: null,
is_mfe_special_exams_enabled: boolean(false),
is_mfe_proctored_exams_enabled: boolean(false),
user_needs_integrity_signature: boolean(false),
},
},
@@ -251,30 +256,28 @@ describe('Courseware Service', () => {
isStaff: true,
license: 'all-rights-reserved',
verifiedMode: {
accessExpirationDate: null,
accessExpirationDate: '2013-02-05T05:00:00Z',
currency: 'USD',
currencySymbol: '$',
price: 149,
sku: '8CF08E5',
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
},
tabs: [
{
title: 'Course',
slug: 'courseware',
priority: 0,
type: 'courseware',
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
},
],
tabs: [{
title: 'Course',
slug: 'courseware',
priority: 0,
type: 'courseware',
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
}],
userTimezone: null,
showCalculator: false,
notes: { enabled: false, visible: true },
marketingUrl: null,
celebrations: {
irstSection: false,
firstSection: false,
streakLengthToCelebrate: null,
streakDiscountExperimentEnabled: false,
streakDiscountEnabled: false,
},
userHasPassingGrade: false,
courseExitPageIsActive: false,
@@ -290,8 +293,6 @@ describe('Courseware Service', () => {
linkedinAddToProfileUrl: null,
relatedPrograms: null,
userNeedsIntegritySignature: false,
specialExamsEnabledWaffleFlag: false,
proctoredExamsEnabledWaffleFlag: false,
isMasquerading: false,
};
const response = await getCourseMetadata(courseId);
@@ -311,8 +312,7 @@ describe('Courseware Service', () => {
},
willRespondWith: {
status: 200,
body:
{
body: {
items: eachLike({
content: '',
page_title: 'Pointing on a Picture',
@@ -327,6 +327,7 @@ describe('Courseware Service', () => {
item_id: string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions'),
is_time_limited: boolean(false),
is_proctored: boolean(false),
is_hidden_after_due: boolean(false),
position: null,
tag: boolean('sequential'),
banner_text: null,
@@ -363,23 +364,22 @@ describe('Courseware Service', () => {
},
isTimeLimited: false,
isProctored: false,
isHiddenAfterDue: false,
activeUnitIndex: 0,
saveUnitPosition: false,
showCompletion: false,
allowProctoringOptOut: undefined,
},
units: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
sequenceId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
bookmarked: false,
complete: undefined,
title: 'Pointing on a Picture',
contentType: 'problem',
graded: true,
containsContentTypeGatedContent: false,
},
],
units: [{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
sequenceId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
bookmarked: false,
complete: undefined,
title: 'Pointing on a Picture',
contentType: 'problem',
graded: true,
containsContentTypeGatedContent: false,
}],
};
const response = await getSequenceMetadata(sequenceId);
expect(response).toBeTruthy();
@@ -399,8 +399,7 @@ describe('Courseware Service', () => {
},
willRespondWith: {
status: 200,
body:
{
body: {
success: boolean(true),
},
},
@@ -423,8 +422,7 @@ describe('Courseware Service', () => {
},
willRespondWith: {
status: 200,
body:
{
body: {
complete: boolean(true),
},
},
@@ -434,4 +432,51 @@ describe('Courseware Service', () => {
expect(response).toEqual(true);
});
});
describe('When a request to get resume block is made', () => {
it('returns block id, section id and unit id of the resume block', async () => {
await provider.addInteraction({
state: `Resume block exists for course_id ${courseId}`,
uponReceiving: 'a request to get Resume block',
withRequest: {
method: 'GET',
path: `/api/courseware/resume/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
block_id: string('642fadf46d074aabb637f20af320fb31'),
section_id: string('642fadf46d074aabb637f20af320fb87'),
unit_id: string('642fadf46d074aabb637f20af320fb99'),
},
},
});
const camelCaseResponse = {
blockId: '642fadf46d074aabb637f20af320fb31',
sectionId: '642fadf46d074aabb637f20af320fb87',
unitId: '642fadf46d074aabb637f20af320fb99',
};
const response = await getResumeBlock(courseId);
expect(response).toBeTruthy();
expect(response).toEqual(camelCaseResponse);
});
});
describe('When a request to send activation email is made', () => {
it('returns status code 200', async () => {
await provider.addInteraction({
state: 'A logged-in user may or may not be active',
uponReceiving: 'a request to send activation email',
withRequest: {
method: 'POST',
path: '/api/send_account_activation_email',
},
willRespondWith: {
status: 200,
},
});
const response = await sendActivationEmail();
expect(response).toEqual('');
});
});
});

View File

@@ -13,16 +13,8 @@ const slice = createSlice({
courseId: null,
sequenceStatus: 'loading',
sequenceId: null,
specialExamsEnabledWaffleFlag: false,
proctoredExamsEnabledWaffleFlag: false,
},
reducers: {
setsSpecialExamsEnabled: (state, { payload }) => {
state.specialExamsEnabledWaffleFlag = payload.specialExamsEnabledWaffleFlag;
},
setsProctoredExamsEnabled: (state, { payload }) => {
state.proctoredExamsEnabledWaffleFlag = payload.proctoredExamsEnabledWaffleFlag;
},
fetchCourseRequest: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = LOADING;
@@ -55,8 +47,6 @@ const slice = createSlice({
});
export const {
setsSpecialExamsEnabled,
setsProctoredExamsEnabled,
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,

View File

@@ -1,4 +1,4 @@
import { logError } from '@edx/frontend-platform/logging';
import { logError, logInfo } from '@edx/frontend-platform/logging';
import {
getBlockCompletion,
postSequencePosition,
@@ -12,8 +12,6 @@ import {
updateModel, addModel, updateModelsMap, addModelsMap, updateModels,
} from '../../generic/model-store';
import {
setsSpecialExamsEnabled,
setsProctoredExamsEnabled,
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,
@@ -145,12 +143,6 @@ export function fetchCourse(courseId) {
modelType: 'coursewareMeta',
model: courseMetadataResult.value,
}));
dispatch(setsSpecialExamsEnabled({
specialExamsEnabledWaffleFlag: courseMetadataResult.value.specialExamsEnabledWaffleFlag,
}));
dispatch(setsProctoredExamsEnabled({
proctoredExamsEnabledWaffleFlag: courseMetadataResult.value.proctoredExamsEnabledWaffleFlag,
}));
}
if (courseBlocksResult.status === 'fulfilled') {
@@ -188,7 +180,14 @@ export function fetchCourse(courseId) {
// Log errors for each request if needed. Course block failures may occur
// even if the course metadata request is successful
if (!fetchedBlocks) {
logError(courseBlocksResult.reason);
const { response } = courseBlocksResult.reason;
if (response && response.status === 403) {
// 403 responses are normal - they happen when the learner is logged out.
// We'll redirect them in a moment to the outline tab by calling fetchCourseDenied() below.
logInfo(courseBlocksResult.reason);
} else {
logError(courseBlocksResult.reason);
}
}
if (!fetchedMetadata) {
logError(courseMetadataResult.reason);
@@ -248,7 +247,7 @@ export function checkBlockCompletion(courseId, sequenceId, unitId) {
return async (dispatch, getState) => {
const { models } = getState();
if (models.units[unitId].complete) {
return; // do nothing. Things don't get uncompleted after they are completed.
return {}; // do nothing. Things don't get uncompleted after they are completed.
}
try {
@@ -260,9 +259,11 @@ export function checkBlockCompletion(courseId, sequenceId, unitId) {
complete: isComplete,
},
}));
return isComplete;
} catch (error) {
logError(error);
}
return {};
};
}
@@ -302,10 +303,15 @@ export function saveSequencePosition(courseId, sequenceId, activeUnitIndex) {
};
}
export function saveIntegritySignature(courseId) {
export function saveIntegritySignature(courseId, isMasquerading) {
return async (dispatch) => {
try {
await postIntegritySignature(courseId);
// If the request is made by a staff user masquerading as a learner,
// don't actually create a signature for them on the backend. Only
// frontend state will be updated.
if (!isMasquerading) {
await postIntegritySignature(courseId);
}
dispatch(updateModel({
modelType: 'coursewareMeta',
model: {

View File

@@ -48,7 +48,7 @@ export const BlockModal = () => (
<div>
<BulletList>
Earn a verified certificate of completion to showcase on your resume
Earn a verified certificate of completion to showcase on your resumé
</BulletList>
<BulletList>
Unlock unlimited access to all course content and activities,

View File

@@ -103,7 +103,7 @@ const Sidecard = ({
&nbsp;<span style={{ fontWeight: 600 }}>graded assignments</span>
</BulletList>
<BulletList>
Earn a {certLink} of completion to showcase on your resume
Earn a {certLink} of completion to showcase on your resumé
</BulletList>
<BulletList>
Support our <span style={{ fontWeight: 600 }}>non-profit mission</span> at edX

View File

@@ -1,6 +1,8 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Spinner } from '@edx/paragon';
export default class PageLoading extends Component {
renderSrMessage() {
if (!this.props.srMessage) {
@@ -23,9 +25,9 @@ export default class PageLoading extends Component {
height: '50vh',
}}
>
<div className="spinner-border text-primary" role="status">
<Spinner animation="border" variant="primary" role="status">
{this.renderSrMessage()}
</div>
</Spinner>
</div>
</div>
);

View File

@@ -11,16 +11,6 @@ const messages = defineMessages({
defaultMessage: 'register',
description: 'Text in a link, prompting the user to create an account. Used in "learning.logistration.alert"',
},
registerSentenceCase: {
id: 'general.register.sentenceCase',
defaultMessage: 'Register',
description: 'Text in a button, prompting the user to register.',
},
signInLowercase: {
id: 'learning.logistration.login', // ID left for historical purposes
defaultMessage: 'sign in',
description: 'Text in a link, prompting the user to log in. Used in "learning.logistration.alert"',
},
signInSentenceCase: {
id: 'general.signIn.sentenceCase',
defaultMessage: 'Sign in',

View File

@@ -0,0 +1,35 @@
import React, { useEffect, useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import PropTypes from 'prop-types';
import { getNotices } from './api';
/**
* This component uses the platform-plugin-notices plugin to function.
* If the user has an unacknowledged notice, they will be rerouted off
* course home and onto a full-screen notice page. If the plugin is not
* installed, or there are no notices, we just passthrough this component.
*/
const NoticesProvider = ({ children }) => {
const [isRedirected, setIsRedirected] = useState();
useEffect(async () => {
if (getConfig().ENABLE_NOTICES) {
const data = await getNotices();
if (data && data.results && data.results.length > 0) {
const { results } = data;
setIsRedirected(true);
window.location.replace(`${results[0]}?next=${window.location.href}`);
}
}
}, []);
return (
<div>
{isRedirected === true ? null : children}
</div>
);
};
NoticesProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export default NoticesProvider;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import {
initializeMockApp,
render,
act,
} from '../../setupTest';
import NoticesProvider from './NoticesProvider';
import { getNotices } from './api';
jest.mock('./api', () => ({
getNotices: jest.fn(),
}));
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
describe('NoticesProvider', () => {
beforeAll(async () => {
jest.resetModules();
await initializeMockApp();
});
function buildAndRender() {
render(
<NoticesProvider>
<div />
</NoticesProvider>,
);
}
it('does not call api if ENABLE_NOTICES is false', () => {
getConfig.mockImplementation(() => ({ ENABLE_NOTICES: false }));
buildAndRender();
expect(getNotices).toHaveBeenCalledTimes(0);
});
it('redirects user on notice returned from API', async () => {
const redirectUrl = 'http://example.com/test_route';
getConfig.mockImplementation(() => ({ ENABLE_NOTICES: true }));
getNotices.mockImplementation(() => ({ results: [redirectUrl] }));
delete window.location;
window.location = { replace: jest.fn() };
process.env.ENABLE_NOTICES = true;
await act(async () => buildAndRender());
expect(window.location.replace).toHaveBeenCalledWith(`${redirectUrl}?next=${window.location.href}`);
});
it('does not redirect on no data', async () => {
getNotices.mockImplementation(() => ({}));
getConfig.mockImplementation(() => ({ ENABLE_NOTICES: true }));
delete window.location;
window.location = { replace: jest.fn() };
process.env.ENABLE_NOTICES = true;
await act(async () => buildAndRender());
expect(window.location.replace).toHaveBeenCalledTimes(0);
expect(window.location.toString() === 'http://localhost/');
});
});

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