Compare commits

..

173 Commits

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

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

updated for feedback

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

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

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

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

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

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

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

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

* Removing all usages of theme-color.

* Removing underline from “Help” button in header

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

* Revert text-decoration-none on Help link

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

* Rebranding breadcrumbs to be primary-500.

* Set styling for Unit title to h3.

* Setting bookmark button color to primary-500

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

* Adjusting sequence dropdown colors

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

* Remove custom btn-outline-light config

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

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

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

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

* Removing unused frontend-component-header module.

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

* Adding quick comment describing the structure of gated_content

* Fixing course exit styling

* Changing LinkedIn icon

* fix: fix Instructor toolbar button styling

* Bumping footer, platform, and paragon versions.

* Using configured logo and favicon.

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

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

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

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

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

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

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

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

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

* AA-196: Course Celebration for passing Verified Learners

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

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

* [TNL-7269] Add low priority tests

* [TNL-7269] Fix failing EnrollmentAlert tests

* [TNL-7269] Address review comments

* Fixing test errors on rebase with master.

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

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

This is Dillon's work.

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

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

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

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

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

* Further masquerade widget fixes

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

* Fixing broken tests and test warnings.

* Remove comment block.

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

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

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

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

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

TNL-7400

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

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

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

* AA-279: Add course expired alert to outline

It was previously only used in the courseware. But to match the
LMS, we also want to show it on the outline tab.
2020-08-06 09:49:37 -04:00
daphneli-chen
4667535c0c AA-211: Created certificate banner and studio link (#134)
Co-authored-by: Daphne Li-Chen <dli-chen@edx.org>
2020-08-05 10:26:34 -04:00
195 changed files with 14418 additions and 5263 deletions

16
.env
View File

@@ -3,17 +3,29 @@ ACCESS_TOKEN_COOKIE_NAME=null
BASE_URL=null
CREDENTIALS_BASE_URL=null
CSRF_TOKEN_API_PATH=null
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=null
ECOMMERCE_BASE_URL=null
INSIGHTS_BASE_URL=
INSIGHTS_BASE_URL=null
LANGUAGE_PREFERENCE_COOKIE_NAME=null
LMS_BASE_URL=null
LOGIN_URL=null
LOGOUT_URL=null
LOGO_URL=null
LOGO_TRADEMARK_URL=null
LOGO_WHITE_URL=null
FAVICON_URL=null
MARKETING_SITE_BASE_URL=null
ORDER_HISTORY_URL=null
REFRESH_ACCESS_TOKEN_ENDPOINT=null
SEARCH_CATALOG_URL=null
SEGMENT_KEY=null
SITE_NAME=null
SOCIAL_UTM_MILESTONE_CAMPAIGN=null
STUDIO_BASE_URL=null
SUPPORT_URL=null
SUPPORT_URL_CALCULATOR_MATH=null
SUPPORT_URL_ID_VERIFICATION=null
SUPPORT_URL_VERIFIED_CERTIFICATE=null
TWITTER_HASHTAG=null
TWITTER_URL=null
STUDIO_BASE_URL=
USER_INFO_COOKIE_NAME=null

View File

@@ -1,19 +1,31 @@
NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:2000'
BASE_URL='http://localhost:2000'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='localhost:1996/orders'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=null
SITE_NAME='edX'
TWITTER_URL='https://twitter.com/edXOnline'
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
STUDIO_BASE_URL='http://localhost:18010'
SUPPORT_URL='https://support.edx.org'
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'

View File

@@ -1,19 +1,31 @@
NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:2000'
BASE_URL='http://localhost:2000'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='localhost:1996/orders'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=null
SITE_NAME='edX'
TWITTER_URL='https://twitter.com/edXOnline'
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
STUDIO_BASE_URL='http://localhost:18010'
SUPPORT_URL='https://support.edx.org'
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'

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

@@ -0,0 +1,21 @@
name: validate
on:
- push
- pull_request
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version:
- 12
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: make validate.ci
- name: Upload coverage
uses: codecov/codecov-action@v1
with:
fail_ci_if_error: true

5
.gitignore vendored
View File

@@ -12,7 +12,10 @@ temp/babel-plugin-react-intl
### pyenv ###
.python-version
### Emacs ###
### Editors ###
*~
/temp
/.vscode
# Local package dependencies
module.config.js

View File

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

View File

@@ -52,3 +52,17 @@ pull_translations:
validate-no-uncommitted-package-lock-changes:
# Checking for package-lock.json changes...
git diff --exit-code package-lock.json
.PHONY: validate
validate:
make validate-no-uncommitted-package-lock-changes
npm run i18n_extract
npm run lint -- --max-warnings 0
npm run test
npm run build
npm run is-es5
.PHONY: validate.ci
validate.ci:
npm ci
make validate

View File

@@ -1,4 +1,4 @@
|Build Status| |Coveralls| |npm_version| |npm_downloads| |license|
|Coveralls| |npm_version| |npm_downloads| |license|
frontend-app-learning
=========================
@@ -10,8 +10,6 @@ Introduction
React app for edX learning.
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-learning.svg?branch=master
:target: https://travis-ci.org/edx/frontend-app-learning
.. |Coveralls| image:: https://img.shields.io/coveralls/edx/frontend-app-learning.svg?branch=master
:target: https://coveralls.io/github/edx/frontend-app-learning
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-learning.svg
@@ -43,3 +41,29 @@ In this project, install requirements and start the development server by runnin
npm start # The server will run on port 1995
Once the dev server is up, visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
Local module development
^^^^^^^^^^^^^^^^^^^^^^^^
To develop locally on modules that are installed into this app, you'll need to create a ``module.config.js``
file (which is git-ignored) that defines where to find your local modules, for instance::
module.exports = {
/*
Modules you want to use from local source code. Adding a module here means that when this app
runs its build, it'll resolve the source from peer directories of this app.
moduleName: the name you use to import code from the module.
dir: The relative path to the module's source code.
dist: The sub-directory of the source code where it puts its build artifact. Often "dist", though you
may want to use "src" if the module installs React as a peer/dev dependency.
*/
localModules: [
{ moduleName: '@edx/paragon/scss', dir: '../paragon', dist: 'scss' },
{ moduleName: '@edx/paragon', dir: '../paragon', dist: 'dist' },
{ moduleName: '@edx/frontend-enterprise', dir: '../frontend-enterprise', dist: 'src' },
{ moduleName: '@edx/frontend-platform', dir: '../frontend-platform', dist: 'dist' },
],
};
See https://github.com/edx/frontend-build#local-module-configuration-for-webpack for more details.

View File

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

8831
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,15 +34,16 @@
"url": "https://github.com/edx/frontend-app-learning/issues"
},
"dependencies": {
"@edx/frontend-component-footer": "10.0.11",
"@edx/frontend-component-header": "2.0.5",
"@edx/frontend-platform": "1.5.2",
"@edx/paragon": "7.2.1",
"@fortawesome/fontawesome-svg-core": "1.2.30",
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.1.2",
"@edx/frontend-enterprise": "4.2.3",
"@edx/frontend-platform": "1.8.1",
"@edx/paragon": "12.3.1",
"@fortawesome/fontawesome-svg-core": "1.2.32",
"@fortawesome/free-brands-svg-icons": "5.13.1",
"@fortawesome/free-regular-svg-icons": "5.13.1",
"@fortawesome/free-solid-svg-icons": "5.13.1",
"@fortawesome/react-fontawesome": "0.1.11",
"@fortawesome/react-fontawesome": "0.1.13",
"@reduxjs/toolkit": "1.3.6",
"classnames": "2.2.6",
"core-js": "3.6.5",
@@ -51,26 +52,28 @@
"react-break": "1.3.2",
"react-dom": "16.13.1",
"react-helmet": "6.0.0",
"react-redux": "7.2.1",
"react-redux": "7.2.2",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-share": "4.2.1",
"redux": "4.0.5",
"regenerator-runtime": "0.13.7",
"reselect": "4.0.0"
"reselect": "4.0.0",
"truncate-html": "1.0.3"
},
"devDependencies": {
"@edx/frontend-build": "5.0.6",
"@edx/frontend-build": "5.5.5",
"@testing-library/dom": "7.16.3",
"@testing-library/jest-dom": "5.10.1",
"@testing-library/react": "10.3.0",
"@testing-library/user-event": "12.0.17",
"axios-mock-adapter": "1.18.2",
"codecov": "3.7.2",
"es-check": "5.1.0",
"es-check": "5.1.4",
"glob": "7.1.6",
"husky": "3.1.0",
"jest": "24.9.0",
"jest-chain": "1.1.5",
"reactifex": "1.1.1",
"rosie": "2.0.1"
}

View File

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

View File

@@ -1,24 +1,140 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedMessage, FormattedDate, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import messages from './messages';
function AccessExpirationAlert({ payload }) {
function AccessExpirationAlert({ intl, payload }) {
const {
rawHtml,
accessExpiration,
userTimezone,
} = payload;
return rawHtml && (
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
if (!accessExpiration) {
return null;
}
const {
expirationDate,
masqueradingExpiredCourse,
upgradeDeadline,
upgradeUrl,
} = accessExpiration;
if (masqueradingExpiredCourse) {
return (
<Alert type={ALERT_TYPES.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>
);
}
let deadlineMessage = null;
if (upgradeDeadline && upgradeUrl) {
deadlineMessage = (
<>
<br />
<FormattedMessage
id="learning.accessExpiration.deadline"
defaultMessage="Upgrade by {date} to get unlimited access to the course as long as it exists on the site."
values={{
date: (
<FormattedDate
key="accessExpirationUpgradeDeadline"
day="numeric"
month="short"
year="numeric"
value={upgradeDeadline}
{...timezoneFormatArgs}
/>
),
}}
/>
&nbsp;
<Hyperlink
className="font-weight-bold"
style={{ textDecoration: 'underline' }}
destination={upgradeUrl}
>
{intl.formatMessage(messages.upgradeNow)}
</Hyperlink>
</>
);
}
return (
<Alert type={ALERT_TYPES.INFO}>
{/* eslint-disable-next-line react/no-danger */}
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
<span className="font-weight-bold">
<FormattedMessage
id="learning.accessExpiration.header"
defaultMessage="Audit Access Expires {date}"
values={{
date: (
<FormattedDate
key="accessExpirationHeaderDate"
day="numeric"
month="short"
year="numeric"
value={expirationDate}
{...timezoneFormatArgs}
/>
),
}}
/>
</span>
<br />
<FormattedMessage
id="learning.accessExpiration.body"
defaultMessage="You lose all access to this course, including your progress, on {date}."
values={{
date: (
<FormattedDate
key="accessExpirationBodyDate"
day="numeric"
month="short"
year="numeric"
value={expirationDate}
{...timezoneFormatArgs}
/>
),
}}
/>
{deadlineMessage}
</Alert>
);
}
AccessExpirationAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
rawHtml: PropTypes.string.isRequired,
accessExpiration: PropTypes.shape({
expirationDate: PropTypes.string.isRequired,
masqueradingExpiredCourse: PropTypes.bool.isRequired,
upgradeDeadline: PropTypes.string,
upgradeUrl: PropTypes.string,
}).isRequired,
userTimezone: PropTypes.string.isRequired,
}).isRequired,
};
export default AccessExpirationAlert;
export default injectIntl(AccessExpirationAlert);

View File

@@ -3,15 +3,16 @@ import { useAlert } from '../../generic/user-messages';
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
function useAccessExpirationAlert(courseExpiredMessage, topic) {
const rawHtml = courseExpiredMessage || null;
const isVisible = !!rawHtml; // If it exists, show it.
const payload = useMemo(() => ({ rawHtml }), [rawHtml]);
function useAccessExpirationAlert(accessExpiration, userTimezone, topic) {
const isVisible = !!accessExpiration; // If it exists, show it.
const payload = {
accessExpiration,
userTimezone,
};
useAlert(isVisible, {
code: 'clientAccessExpirationAlert',
payload,
payload: useMemo(() => payload, Object.values(payload).sort()),
topic,
});

View File

@@ -0,0 +1,10 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
upgradeNow: {
id: 'learning.accessExpiration.upgradeNow',
defaultMessage: 'Upgrade now',
},
});
export default messages;

View File

@@ -5,6 +5,7 @@ import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { useModel } from '../../generic/model-store';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import messages from './messages';
@@ -18,8 +19,13 @@ function EnrollmentAlert({ intl, payload }) {
isStaff,
} = payload;
const {
org,
} = useModel('courseHomeMeta', courseId);
const { enrollClickHandler, loading } = useEnrollClickHandler(
courseId,
org,
intl.formatMessage(messages.success),
);
@@ -33,8 +39,8 @@ function EnrollmentAlert({ intl, payload }) {
}
const button = canEnroll && (
<Button disabled={loading} className="btn-link p-0 border-0 align-top" onClick={enrollClickHandler}>
{intl.formatMessage(messages.enroll)}
<Button disabled={loading} variant="link" className="p-0 border-0 align-top" style={{ textDecoration: 'underline' }} onClick={enrollClickHandler}>
{intl.formatMessage(messages.enrollNowSentence)}
</Button>
);

View File

@@ -1,7 +1,9 @@
/* eslint-disable import/prefer-default-export */
import React, {
useContext, useState, useCallback,
useContext, useState, useCallback, useMemo,
} from 'react';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { AppContext } from '@edx/frontend-platform/react';
import { UserMessagesContext, ALERT_TYPES, useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
@@ -11,25 +13,35 @@ import { postCourseEnrollment } from './data/api';
const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert'));
export function useEnrollmentAlert(courseId) {
const course = useModel('courses', courseId);
const { authenticatedUser } = useContext(AppContext);
const course = useModel('courseHomeMeta', courseId);
const outline = useModel('outline', courseId);
const isVisible = course && course.isEnrolled !== undefined && !course.isEnrolled;
const enrolledUser = course && course.isEnrolled !== undefined && course.isEnrolled;
const privateOutline = outline && outline.courseBlocks && !outline.courseBlocks.courses;
/**
* This alert should render if
* 1. the user is not enrolled,
* 2. the user is authenticated, AND
* 3. the course is private.
*/
const isVisible = !enrolledUser && authenticatedUser !== null && privateOutline;
const payload = {
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
courseId,
extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '',
isStaff: course && course.isStaff,
};
useAlert(isVisible, {
code: 'clientEnrollmentAlert',
payload: {
canEnroll: outline.enrollAlert.canEnroll,
courseId,
extraText: outline.enrollAlert.extraText,
isStaff: course.isStaff,
},
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline',
});
return { clientEnrollmentAlert: EnrollmentAlert };
}
export function useEnrollClickHandler(courseId, successText) {
export function useEnrollClickHandler(courseId, orgId, successText) {
const [loading, setLoading] = useState(false);
const { addFlash } = useContext(UserMessagesContext);
const enrollClickHandler = useCallback(() => {
@@ -43,6 +55,10 @@ export function useEnrollClickHandler(courseId, successText) {
topic: 'course',
});
setLoading(false);
sendTrackEvent('edx.bi.user.course-home.enrollment', {
org_key: orgId,
courserun_key: courseId,
});
global.location.reload();
});
}, [courseId]);

View File

@@ -11,9 +11,15 @@ const messages = defineMessages({
defaultMessage: 'You are viewing this course as staff, and are not enrolled.',
description: 'Message shown to indicate that a user is not enrolled, but is able to view a course anyway because they are staff. Shown as part of an alert, along with a link to enroll.',
},
enroll: {
id: 'learning.enrollment.enroll.now',
defaultMessage: 'Enroll Now',
enrollNowInline: {
id: 'learning.enrollment.enrollNow.Inline',
defaultMessage: 'Enroll now',
description: 'A link prompting the user to click on it to enroll in the currently viewed course.'
+ 'This text is meant to be used at the beginning of a sentence (example: Enroll now to view course content.)',
},
enrollNowSentence: {
id: 'learning.enrollment.enrollNow.Sentence',
defaultMessage: 'Enroll now.',
description: 'A link prompting the user to click on it to enroll in the currently viewed course.',
},
success: {

View File

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

View File

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

View File

@@ -1,16 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
login: {
id: 'learning.logistration.login',
defaultMessage: 'sign in',
description: 'Text in a link, prompting the user to log in. Used in "learning.logistration.alert"',
},
register: {
id: 'learning.logistration.register',
defaultMessage: 'register',
description: 'Text in a link, prompting the user to create an account. Used in "learning.logistration.alert"',
},
});
export default messages;

View File

@@ -1,24 +1,87 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedMessage, FormattedDate, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import { FormattedPricing } from '../../generic/upgrade-button';
import messages from './messages';
function OfferAlert({ payload }) {
function OfferAlert({ intl, payload }) {
const {
rawHtml,
offer,
userTimezone,
} = payload;
return rawHtml && (
if (!offer) {
return null;
}
const {
code,
expirationDate,
percentage,
upgradeUrl,
} = offer;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<Alert type={ALERT_TYPES.INFO}>
{/* eslint-disable-next-line react/no-danger */}
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
{/* the first-purchase-offer-banner class can be removed post REV-1512 experiment */}
<span className="font-weight-bold first-purchase-offer-banner">
<FormattedMessage
id="learning.offer.header"
defaultMessage="Upgrade by {date} and save {percentage}% [{fullPricing}]"
values={{
date: (
<FormattedDate
key="offerDate"
day="numeric"
month="long"
value={expirationDate}
{...timezoneFormatArgs}
/>
),
fullPricing: <FormattedPricing offer={offer} />,
percentage,
}}
/>
</span>
<br />
<FormattedMessage
id="learning.offer.code"
defaultMessage="Use code {code} at checkout!"
values={{
code: (<b>{code}</b>),
}}
/>
&nbsp;
<Hyperlink
className="font-weight-bold"
style={{ textDecoration: 'underline' }}
destination={upgradeUrl}
>
{intl.formatMessage(messages.upgradeNow)}
</Hyperlink>
</Alert>
);
}
OfferAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
rawHtml: PropTypes.string.isRequired,
offer: PropTypes.shape({
code: PropTypes.string.isRequired,
discountedPrice: PropTypes.string.isRequired,
expirationDate: PropTypes.string.isRequired,
originalPrice: PropTypes.string.isRequired,
percentage: PropTypes.number.isRequired,
upgradeUrl: PropTypes.string.isRequired,
}).isRequired,
userTimezone: PropTypes.string.isRequired,
}).isRequired,
};
export default OfferAlert;
export default injectIntl(OfferAlert);

View File

@@ -1,16 +1,19 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useAlert } from '../../generic/user-messages';
const OfferAlert = React.lazy(() => import('./OfferAlert'));
export function useOfferAlert(offerHtml, topic) {
const rawHtml = offerHtml || null;
const isVisible = !!rawHtml; // if it exists, show it.
export function useOfferAlert(offer, userTimezone, topic) {
const isVisible = !!offer; // if it exists, show it.
const payload = {
offer,
userTimezone,
};
useAlert(isVisible, {
code: 'clientOfferAlert',
topic,
payload: { rawHtml },
payload: useMemo(() => payload, Object.values(payload).sort()),
});
return { clientOfferAlert: OfferAlert };

View File

@@ -0,0 +1,10 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
upgradeNow: {
id: 'learning.offer.upgradeNow',
defaultMessage: 'Upgrade now',
},
});
export default messages;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -0,0 +1,34 @@
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

@@ -0,0 +1,74 @@
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 = {
enterpriseLearnerPortalLink: PropTypes.string,
intl: intlShape.isRequired,
username: PropTypes.string.isRequired,
};
AuthenticatedUserDropdown.defaultProps = {
enterpriseLearnerPortalLink: '',
};
export default injectIntl(AuthenticatedUserDropdown);

View File

@@ -14,7 +14,7 @@ function CourseTabsNavigation({
<div className="container-fluid">
<Tabs
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages['learn.navigation.course.tabs.label'])}
aria-label={intl.formatMessage(messages.courseMaterial)}
>
{tabs.map(({ url, title, slug }) => (
<a

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { initializeMockApp, render, screen } from '../setupTest';
import { CourseTabsNavigation } from './index';
describe('Course Tabs Navigation', () => {
beforeAll(async () => {
initializeMockApp();
});
it('renders without tabs', () => {
render(<CourseTabsNavigation tabs={[]} />);
expect(screen.getByRole('button', { name: 'More...' })).toBeInTheDocument();
});
it('renders with tabs', () => {
const tabs = [
{ url: 'http://test-url1', title: 'Item 1', slug: 'test1' },
{ url: 'http://test-url2', title: 'Item 2', slug: 'test2' },
];
const mockData = {
tabs,
activeTabSlug: tabs[0].slug,
};
render(<CourseTabsNavigation {...mockData} />);
expect(screen.getByRole('link', { name: tabs[0].title }))
.toHaveAttribute('href', tabs[0].url)
.toHaveClass('active');
expect(screen.getByRole('link', { name: tabs[1].title }))
.toHaveAttribute('href', tabs[1].url)
.not.toHaveClass('active');
});
});

View File

@@ -1,13 +1,13 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { Dropdown } from '@edx/paragon';
import { useEnterpriseConfig } from '@edx/frontend-enterprise';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
import logo from './assets/logo.svg';
import AnonymousUserMenu from './AnonymousUserMenu';
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
import messages from './messages';
function LinkedLogo({
href,
@@ -28,40 +28,54 @@ LinkedLogo.propTypes = {
alt: PropTypes.string.isRequired,
};
export default function Header({
courseOrg, courseNumber, courseTitle,
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">
<div className="container-fluid py-2 d-flex align-items-center ">
<LinkedLogo
className="logo"
href={`${getConfig().LMS_BASE_URL}/dashboard`}
src={logo}
alt={getConfig().SITE_NAME}
/>
<a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a>
<div className="container-fluid 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>
<Dropdown className="user-dropdown">
<Dropdown.Button>
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
<span className="d-none d-md-inline">
{authenticatedUser.username}
</span>
</Dropdown.Button>
<Dropdown.Menu className="dropdown-menu-right">
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>Dashboard</Dropdown.Item>
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${authenticatedUser.username}`}>Profile</Dropdown.Item>
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>Account</Dropdown.Item>
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>Order History</Dropdown.Item>
<Dropdown.Item href={getConfig().LOGOUT_URL}>Sign Out</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
{authenticatedUser && (
<AuthenticatedUserDropdown
enterpriseLearnerPortalLink={enterpriseLearnerPortalLink}
username={authenticatedUser.username}
/>
)}
{!authenticatedUser && (
<AnonymousUserMenu />
)}
</div>
</header>
);
@@ -71,6 +85,7 @@ Header.propTypes = {
courseOrg: PropTypes.string,
courseNumber: PropTypes.string,
courseTitle: PropTypes.string,
intl: intlShape.isRequired,
};
Header.defaultProps = {
@@ -78,3 +93,5 @@ Header.defaultProps = {
courseNumber: null,
courseTitle: null,
};
export default injectIntl(Header);

View File

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

Before

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -1,11 +1,46 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'learn.navigation.course.tabs.label': {
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

@@ -0,0 +1,59 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('block')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
.option('host', 'http://localhost:18000')
// Generating block_id that is similar to md5 hash, but still deterministic
.sequence('block_id', id => ('abcd'.repeat(8) + id).slice(-32))
.attrs({
complete: false,
description: null,
due: null,
graded: false,
icon: null,
showLink: true,
type: 'course',
children: [],
})
.attr('display_name', ['display_name', 'block_id'], (displayName, blockId) => {
if (displayName) {
return displayName;
}
return blockId;
})
.attr(
'id',
['id', 'block_id', 'type', 'courseId'],
(id, blockId, type, courseId) => {
if (id) {
return id;
}
const courseInfo = courseId.split(':')[1];
return `block-v1:${courseInfo}+type@${type}+block@${blockId}`;
},
)
.attr(
'student_view_url',
['student_view_url', 'host', 'id'],
(url, host, id) => {
if (url) {
return url;
}
return `${host}/xblock/${id}`;
},
)
.attr(
'lms_web_url',
['lms_web_url', 'host', 'courseId', 'id'],
(url, host, courseId, id) => {
if (url) {
return url;
}
return `${host}/courses/${courseId}/jump_to/${id}`;
},
);

View File

@@ -0,0 +1,92 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import './block.factory';
// Generates an Array of block IDs, either from a single block or an array of blocks.
const getIds = (attr) => {
const blocks = Array.isArray(attr) ? attr : [attr];
return blocks.map(block => block.id);
};
// Generates an Object in { [block.id]: block } format, either from a single block or an array of blocks.
const getBlocks = (attr) => {
const blocks = Array.isArray(attr) ? attr : [attr];
// eslint-disable-next-line no-return-assign,no-sequences
return blocks.reduce((acc, block) => (acc[block.id] = block, acc), {});
};
Factory.define('courseBlocks')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
.option('units', ['courseId'], courseId => ([
Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
),
]))
.option('sequence', ['courseId', 'units'], (courseId, child) => Factory.build(
'block',
{ type: 'sequential', children: getIds(child) },
{ courseId },
))
.option('section', ['courseId', 'sequence'], (courseId, child) => Factory.build(
'block',
{ type: 'chapter', children: getIds(child) },
{ courseId },
))
.option('course', ['courseId', 'section'], (courseId, child) => Factory.build(
'block',
{ type: 'course', children: getIds(child) },
{ courseId },
))
.attr(
'blocks',
['course', 'section', 'sequence', 'units'],
(course, section, sequence, units) => ({
[course.id]: course,
...getBlocks(section),
...getBlocks(sequence),
...getBlocks(units),
}),
)
.attr('root', ['course'], course => course.id);
/**
* Builds a course with a single chapter, sequence, and unit.
*/
export default function buildSimpleCourseBlocks(courseId, title, options = {}) {
const sequenceBlock = options.sequenceBlock || [Factory.build(
'block',
{ type: 'sequential' },
{ courseId },
)];
const sectionBlock = options.sectionBlock || Factory.build(
'block',
{
type: 'chapter',
display_name: 'Title of Section',
complete: options.complete || false,
resume_block: options.resumeBlock || false,
children: sequenceBlock.map(block => block.id),
},
{ courseId },
);
const courseBlock = options.courseBlock || Factory.build(
'block',
{ type: 'course', display_name: title, children: [sectionBlock.id] },
{ courseId },
);
return {
courseBlocks: options.courseBlocks || Factory.build(
'courseBlocks',
{ courseId },
{
sequence: sequenceBlock,
section: sectionBlock,
course: courseBlock,
},
),
sequenceBlock,
sectionBlock,
courseBlock,
};
}

View File

@@ -2,22 +2,89 @@ import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dep
Factory.define('courseHomeMetadata')
.sequence(
'course_id',
(courseId) => `course-v1:edX+DemoX+Demo_Course_${courseId}`,
'courseId', (courseId) => `course-v1:edX+DemoX+Demo_Course_${courseId}`,
)
.option('courseTabs', [])
.option('host', 'http://localhost:18000')
.attrs({
is_staff: false,
original_user_is_staff: false,
number: 'DemoX',
org: 'edX',
title: 'Demonstration Course',
is_self_paced: false,
is_enrolled: false,
})
.attr('tabs', ['courseTabs', 'host'], (courseTabs, host) => courseTabs.map(
tab => ({
tab_id: tab.slug,
title: tab.title,
url: `${host}${tab.url}`,
}),
));
.attr(
'tabs', ['courseId', 'host'], (courseId, host) => {
const tabs = [
Factory.build(
'tab',
{
title: 'Course',
priority: 0,
slug: 'courseware',
type: 'courseware',
},
{ courseId, path: 'course/' },
),
Factory.build(
'tab',
{
title: 'Discussion',
priority: 1,
slug: 'discussion',
type: 'discussion',
},
{ courseId, path: 'discussion/forum/' },
),
Factory.build(
'tab',
{
title: 'Wiki',
priority: 2,
slug: 'wiki',
type: 'wiki',
},
{ courseId, path: 'course_wiki' },
),
Factory.build(
'tab',
{
title: 'Progress',
priority: 3,
slug: 'progress',
type: 'progress',
},
{ courseId, path: 'progress' },
),
Factory.build(
'tab',
{
title: 'Instructor',
priority: 4,
slug: 'instructor',
type: 'instructor',
},
{ courseId, path: 'instructor' },
),
Factory.build(
'tab',
{
title: 'Dates',
priority: 5,
slug: 'dates',
type: 'dates',
},
{ courseId, path: 'dates' },
),
];
return tabs.map(
tab => ({
tab_id: tab.slug,
title: tab.title,
url: `${host}${tab.url}`,
}),
);
},
);

View File

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

View File

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

View File

@@ -1,26 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Data layer integration tests Should initialize store 1`] = `
Object {
"courseHome": Object {
"courseId": null,
"courseStatus": "loading",
},
"courseware": Object {
"courseId": null,
"courseStatus": "loading",
"sequenceId": null,
"sequenceStatus": "loading",
},
"models": Object {},
}
`;
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded",
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
},
"courseware": Object {
"courseId": null,
@@ -29,17 +16,19 @@ Object {
"sequenceStatus": "loading",
},
"models": Object {
"courses": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"tabs": Array [
Object {
"slug": "courseware",
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
},
@@ -63,6 +52,11 @@ Object {
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
},
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
},
],
"title": "Demonstration Course",
},
@@ -71,27 +65,220 @@ Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"courseDateBlocks": Array [
Object {
"assigmentType": "Homework",
"date": "2013-02-05T05:00:00Z",
"date": "2020-05-01T17:59:41Z",
"dateType": "course-start-date",
"description": "",
"extraInfo": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "",
"title": "Course Starts",
},
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-04T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Multi Badges Completed",
},
Object {
"assignmentType": "Homework",
"date": "2020-05-05T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Multi Badges Past Due",
},
Object {
"assignmentType": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Past Due 1",
},
Object {
"assignmentType": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Past Due 2",
},
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-28T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Completed/Due 1",
},
Object {
"assignmentType": "Homework",
"date": "2020-05-28T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Completed/Due 2",
},
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Completed 1",
},
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Completed 2",
},
Object {
"date": "2020-06-16T17:59:40.942669Z",
"dateType": "verified-upgrade-deadline",
"description": "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Upgrade to Verified Certificate",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "One Verified 1",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Verified 2",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": "ORA Dates are set by the instructor, and can't be changed",
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "ORA Verified 2",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "Both Verified 1",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "Both Verified 2",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"learnerHasAccess": true,
"title": "One Unreleased 1",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Unreleased 2",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Both Unreleased 1",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Both Unreleased 2",
},
Object {
"date": "2030-08-23T00:00:00Z",
"dateType": "course-end-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "",
"title": "Course Ends",
},
Object {
"date": "2030-09-01T00:00:00Z",
"dateType": "verification-deadline-date",
"description": "You must successfully complete verification before this date to qualify for a Verified Certificate.",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "Verification Deadline",
},
],
"datesBannerInfo": Object {
"contentTypeGatingEnabled": false,
"missedDeadlines": false,
"missedGatedContent": false,
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
"hasEnded": false,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"learnerIsFullAccess": true,
"missedDeadlines": false,
"missedGatedContent": false,
"userTimezone": null,
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
"userTimezone": "America/New_York",
},
},
},
@@ -103,6 +290,9 @@ Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded",
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
},
"courseware": Object {
"courseId": null,
@@ -111,17 +301,19 @@ Object {
"sequenceStatus": "loading",
},
"models": Object {
"courses": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"tabs": Array [
Object {
"slug": "courseware",
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
},
@@ -145,67 +337,95 @@ Object {
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
},
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
},
],
"title": "Demonstration Course",
},
},
"outline": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"accessExpiration": null,
"canShowUpgradeSock": true,
"courseBlocks": Object {
"courses": Object {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd4": Object {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
"id": "course-v1:edX+DemoX+Demo_Course_1",
"sectionIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
],
"title": "bcdabcdabcdabcdabcdabcdabcdabcd4",
},
},
"sections": Object {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
"sequenceIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
],
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
},
},
"sequences": Object {
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
"title": "bcdabcdabcdabcdabcdabcdabcdabcd2",
"unitIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"sections": Object {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
"complete": false,
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"resumeBlock": false,
"sequenceIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
],
"title": "Title of Section",
},
},
"units": Object {
"block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
"graded": false,
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"sequenceId": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"sequences": Object {
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
"complete": false,
"description": null,
"due": null,
"icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"showLink": true,
"title": "bcdabcdabcdabcdabcdabcdabcdabcd1",
},
},
},
"courseTools": Object {
"analyticsId": "edx.bookmarks",
"title": "Bookmarks",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/bookmarks/",
"courseGoals": Object {
"goalOptions": Array [],
"selectedGoal": null,
},
"courseTools": Array [
Object {
"analyticsId": "edx.bookmarks",
"title": "Bookmarks",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/bookmarks/",
},
],
"datesBannerInfo": Object {
"contentTypeGatingEnabled": false,
"missedDeadlines": false,
"missedGatedContent": false,
},
"datesWidget": Object {
"courseDateBlocks": Array [],
"userTimezone": "UTC",
},
"datesWidget": undefined,
"enrollAlert": Object {
"canEnroll": true,
"extraText": "Contact the administrator.",
},
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
"hasEnded": undefined,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"offerHtml": "<div>Great offer here</div>",
"welcomeMessageHtml": undefined,
"offer": null,
"resumeCourse": Object {
"hasVisitedCourse": false,
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
},
"verifiedMode": Object {
"accessExpirationDate": "2050-01-01T12:00:00",
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "ABCD1234",
"upgradeUrl": "http://localhost:18000/dashboard",
},
"welcomeMessageHtml": "<p>Welcome to this course!</p>",
},
},
},

View File

@@ -1,27 +1,102 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
// TODO: Pull this normalization function up so we're not reaching into courseware
import { normalizeBlocks } from '../../courseware/data/api';
import { logInfo } from '@edx/frontend-platform/logging';
function normalizeCourseHomeCourseMetadata(metadata) {
const data = camelCaseObject(metadata);
return {
...data,
tabs: data.tabs.map(tab => ({
slug: tab.tabId,
// The API uses "courseware" as a slug for both courseware and the outline tab. We switch it to "outline" here for
// use within the MFE to differentiate between course home and courseware.
slug: tab.tabId === 'courseware' ? 'outline' : tab.tabId,
title: tab.title,
url: tab.url,
})),
};
}
export function normalizeOutlineBlocks(courseId, blocks) {
const models = {
courses: {},
sections: {},
sequences: {},
};
Object.values(blocks).forEach(block => {
switch (block.type) {
case 'course':
models.courses[block.id] = {
id: courseId,
title: block.display_name,
sectionIds: block.children || [],
};
break;
case 'chapter':
models.sections[block.id] = {
complete: block.complete,
id: block.id,
title: block.display_name,
resumeBlock: block.resume_block,
sequenceIds: block.children || [],
};
break;
case 'sequential':
models.sequences[block.id] = {
complete: block.complete,
description: block.description,
due: block.due,
icon: block.icon,
id: block.id,
showLink: !!block.lms_web_url, // we reconstruct the url ourselves as an MFE-internal <Link>
title: block.display_name,
};
break;
default:
logInfo(`Unexpected course block type: ${block.type} with ID ${block.id}. Expected block types are course, chapter, and sequential.`);
}
});
// Next go through each list and use their child lists to decorate those children with a
// reference back to their parent.
Object.values(models.courses).forEach(course => {
if (Array.isArray(course.sectionIds)) {
course.sectionIds.forEach(sectionId => {
const section = models.sections[sectionId];
section.courseId = course.id;
});
}
});
Object.values(models.sections).forEach(section => {
if (Array.isArray(section.sequenceIds)) {
section.sequenceIds.forEach(sequenceId => {
if (sequenceId in models.sequences) {
models.sequences[sequenceId].sectionId = section.id;
} else {
logInfo(`Section ${section.id} has child block ${sequenceId}, but that block is not in the list of sequences.`);
}
});
}
});
return models;
}
export async function getCourseHomeCourseMetadata(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeCourseHomeCourseMetadata(data);
}
// For debugging purposes, you might like to see a fully loaded dates tab.
// Just uncomment the next few lines and the immediate 'return' in the function below
// import { Factory } from 'rosie';
// import './__factories__';
export async function getDatesTabData(courseId) {
// return camelCaseObject(Factory.build('datesTabData'));
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
@@ -32,6 +107,10 @@ export async function getDatesTabData(courseId) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
return {};
}
if (httpErrorStatus === 401) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`);
return {};
}
throw error;
}
}
@@ -51,6 +130,20 @@ export async function getProgressTabData(courseId) {
}
}
export async function getProctoringInfoData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return data;
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
return {};
}
throw error;
}
}
export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
let { tabData } = {};
@@ -68,33 +161,66 @@ export async function getOutlineTabData(courseId) {
const {
data,
} = tabData;
const courseBlocks = normalizeBlocks(courseId, data.course_blocks.blocks);
const courseExpiredHtml = data.course_expired_html;
const accessExpiration = camelCaseObject(data.access_expiration);
const canShowUpgradeSock = data.can_show_upgrade_sock;
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
const courseGoals = camelCaseObject(data.course_goals);
const courseTools = camelCaseObject(data.course_tools);
const datesBannerInfo = camelCaseObject(data.dates_banner_info);
const datesWidget = camelCaseObject(data.dates_widget);
const enrollAlert = camelCaseObject(data.enroll_alert);
const handoutsHtml = data.handouts_html;
const offerHtml = data.offer_html;
const hasEnded = data.has_ended;
const offer = camelCaseObject(data.offer);
const resumeCourse = camelCaseObject(data.resume_course);
const verifiedMode = camelCaseObject(data.verified_mode);
const welcomeMessageHtml = data.welcome_message_html;
return {
accessExpiration,
canShowUpgradeSock,
courseBlocks,
courseExpiredHtml,
courseGoals,
courseTools,
datesBannerInfo,
datesWidget,
enrollAlert,
handoutsHtml,
offerHtml,
hasEnded,
offer,
resumeCourse,
verifiedMode,
welcomeMessageHtml,
};
}
export async function postCourseDeadlines(courseId) {
export async function postCourseDeadlines(courseId, model) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`);
await getAuthenticatedHttpClient().post(url.href, { course_key: courseId });
return getAuthenticatedHttpClient().post(url.href, {
course_key: courseId,
research_event_data: { location: `${model}-tab` },
});
}
export async function postCourseGoals(courseId, goalKey) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/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`);
await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
}
export async function postRequestCert(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/generate_user_cert`);
await getAuthenticatedHttpClient().post(url.href);
}
export async function executePostFromPostEvent(postData, researchEventData) {
const url = new URL(postData.url);
return getAuthenticatedHttpClient().post(url.href, {
course_key: postData.bodyParams.courseId,
research_event_data: researchEventData,
});
}

View File

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

View File

@@ -8,7 +8,7 @@ import * as thunks from './thunks';
import executeThunk from '../../utils';
import initializeMockApp from '../../setupTest';
import { initializeMockApp } from '../../setupTest';
import initializeStore from '../../store';
const { loggingService } = initializeMockApp();
@@ -16,20 +16,9 @@ const { loggingService } = initializeMockApp();
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Data layer integration tests', () => {
const courseMetadata = Factory.build('courseMetadata');
const courseHomeMetadata = Factory.build(
'courseHomeMetadata', {
course_id: courseMetadata.id,
},
{ courseTabs: courseMetadata.tabs },
);
const courseId = courseMetadata.id;
const courseBaseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course`;
const courseMetadataBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata`;
const courseUrl = `${courseBaseUrl}/${courseId}`;
const courseMetadataUrl = `${courseMetadataBaseUrl}/${courseId}`;
const courseHomeMetadata = Factory.build('courseHomeMetadata');
const { courseId } = courseHomeMetadata;
const courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
let store;
@@ -40,15 +29,10 @@ describe('Data layer integration tests', () => {
store = initializeStore();
});
it('Should initialize store', () => {
expect(store.getState()).toMatchSnapshot();
});
describe('Test fetchDatesTab', () => {
const datesBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates`;
it('Should fail to fetch if error occurs', async () => {
axiosMock.onGet(courseUrl).networkError();
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).networkError();
@@ -63,7 +47,6 @@ describe('Data layer integration tests', () => {
const datesUrl = `${datesBaseUrl}/${courseId}`;
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(datesUrl).reply(200, datesTabData);
@@ -79,7 +62,6 @@ describe('Data layer integration tests', () => {
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline`;
it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseUrl).networkError();
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
@@ -94,7 +76,6 @@ describe('Data layer integration tests', () => {
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
@@ -106,19 +87,32 @@ 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`;
axiosMock.onPost(goalUrl).reply(200, {});
await thunks.saveCourseGoal(courseId, 'unsure');
expect(axiosMock.history.post[0].url).toEqual(goalUrl);
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}","goal_key":"unsure"}`);
});
});
describe('Test resetDeadlines', () => {
it('Should reset course deadlines', async () => {
const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
axiosMock.onPost(resetUrl).reply(201);
const model = 'dates';
axiosMock.onPost(resetUrl).reply(201, {});
const getTabDataMock = jest.fn(() => ({
type: 'MOCK_ACTION',
}));
await executeThunk(thunks.resetDeadlines(courseId, getTabDataMock), store.dispatch);
await executeThunk(thunks.resetDeadlines(courseId, model, getTabDataMock), store.dispatch);
expect(axiosMock.history.post[0].url).toEqual(resetUrl);
expect(axiosMock.history.post[0].data).toEqual(`{"course_key":"${courseId}"}`);
expect(axiosMock.history.post[0].data).toEqual(`{"course_key":"${courseId}","research_event_data":{"location":"dates-tab"}}`);
expect(getTabDataMock).toHaveBeenCalledWith(courseId);
});

View File

@@ -10,6 +10,9 @@ const slice = createSlice({
initialState: {
courseStatus: 'loading',
courseId: null,
toastBodyText: null,
toastBodyLink: null,
toastHeader: '',
},
reducers: {
fetchTabRequest: (state, { payload }) => {
@@ -24,6 +27,16 @@ const slice = createSlice({
state.courseId = payload.courseId;
state.courseStatus = FAILED;
},
setCallToActionToast: (state, { payload }) => {
const {
header,
link,
linkText,
} = payload;
state.toastBodyLink = link;
state.toastBodyText = linkText;
state.toastHeader = header;
},
},
});
@@ -31,6 +44,7 @@ export const {
fetchTabRequest,
fetchTabSuccess,
fetchTabFailure,
setCallToActionToast,
} = slice.actions;
export const {

View File

@@ -1,11 +1,15 @@
import { logError } from '@edx/frontend-platform/logging';
import { camelCaseObject } from '@edx/frontend-platform';
import {
executePostFromPostEvent,
getCourseHomeCourseMetadata,
getDatesTabData,
getOutlineTabData,
getProgressTabData,
postCourseDeadlines,
postCourseGoals,
postDismissWelcomeMessage,
postRequestCert,
} from './api';
import {
@@ -16,8 +20,13 @@ import {
fetchTabFailure,
fetchTabRequest,
fetchTabSuccess,
setCallToActionToast,
} from './slice';
const eventTypes = {
POST_EVENT: 'post_event',
};
export function fetchTab(courseId, tab, getTabData) {
return async (dispatch) => {
dispatch(fetchTabRequest({ courseId }));
@@ -30,7 +39,7 @@ export function fetchTab(courseId, tab, getTabData) {
if (fetchedCourseHomeCourseMetadata) {
dispatch(addModel({
modelType: 'courses',
modelType: 'courseHomeMeta',
model: {
id: courseId,
...courseHomeCourseMetadataResult.value,
@@ -73,14 +82,50 @@ export function fetchOutlineTab(courseId) {
return fetchTab(courseId, 'outline', getOutlineTabData);
}
export function resetDeadlines(courseId, getTabData) {
export function dismissWelcomeMessage(courseId) {
return async () => postDismissWelcomeMessage(courseId);
}
export function requestCert(courseId) {
return async () => postRequestCert(courseId);
}
export function resetDeadlines(courseId, model, getTabData) {
return async (dispatch) => {
postCourseDeadlines(courseId).then(() => {
postCourseDeadlines(courseId, model).then(response => {
const { data } = response;
const {
header,
link,
link_text: linkText,
} = data;
dispatch(getTabData(courseId));
dispatch(setCallToActionToast({ header, link, linkText }));
});
};
}
export function dismissWelcomeMessage(courseId) {
return async () => postDismissWelcomeMessage(courseId);
export async function saveCourseGoal(courseId, goalKey) {
return postCourseGoals(courseId, goalKey);
}
export function processEvent(eventData, getTabData) {
return async (dispatch) => {
// Pulling this out early so the data doesn't get camelCased and is easier
// to use when it's passed to the backend
const { research_event_data: researchEventData } = eventData;
const event = camelCaseObject(eventData);
if (event.eventName === eventTypes.POST_EVENT) {
executePostFromPostEvent(event.postData, researchEventData).then(response => {
const { data } = response;
const {
header,
link,
link_text: linkText,
} = data;
dispatch(getTabData(event.postData.bodyParams.courseId));
dispatch(setCallToActionToast({ header, link, linkText }));
});
}
};
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import messages from './messages';
@@ -14,17 +15,17 @@ function DatesBanner(props) {
return (
<div className="banner rounded my-4 p-4 container-fluid border border-primary-200 bg-info-100">
<div className="row w-100 m-0 justify-content-start justify-content-sm-between">
<div className={name === 'datesTabInfoBanner' ? 'col-12' : 'col-12 col-md-9'}>
<div className={name === 'datesTabInfoBanner' ? 'col-12' : 'col-12 col-lg-9'}>
<strong>
{intl.formatMessage(messages[`datesBanner.${name}.header`])}
</strong>
{intl.formatMessage(messages[`datesBanner.${name}.body`])}
</div>
{bannerClickHandler && (
<div className="col-auto col-md-3 p-md-0 d-inline-flex align-items-center justify-content-start justify-content-md-center">
<button type="button" className="btn rounded align-self-center border border-primary bg-white mt-3 mt-md-0 font-weight-bold" onClick={bannerClickHandler}>
<div className="col-auto col-lg-3 p-lg-0 d-inline-flex align-items-center justify-content-start justify-content-lg-center">
<Button variant="outline-primary" className="align-self-center mt-3 mt-lg-0" onClick={bannerClickHandler}>
{intl.formatMessage(messages[`datesBanner.${name}.button`])}
</button>
</Button>
</div>
)}
</div>

View File

@@ -5,23 +5,19 @@ import { useDispatch, useSelector } from 'react-redux';
import { useModel } from '../../generic/model-store';
import DatesBanner from './DatesBanner';
import { fetchDatesTab, resetDeadlines } from '../data/thunks';
function DatesBannerContainer(props) {
const {
model,
} = props;
import { resetDeadlines } from '../data';
function DatesBannerContainer({
courseDateBlocks,
datesBannerInfo,
hasEnded,
model,
tabFetch,
}) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
courseDateBlocks,
datesBannerInfo,
hasEnded,
} = useModel(model, courseId);
const {
contentTypeGatingEnabled,
missedDeadlines,
@@ -31,7 +27,7 @@ function DatesBannerContainer(props) {
const {
isSelfPaced,
} = useModel('courses', courseId);
} = useModel('courseHomeMeta', courseId);
const dispatch = useDispatch();
const hasDeadlines = courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
@@ -45,18 +41,20 @@ function DatesBannerContainer(props) {
},
{
name: 'upgradeToCompleteGradedBanner',
shouldDisplay: upgradeToCompleteGraded,
clickHandler: () => window.location.replace(verifiedUpgradeLink),
// verifiedUpgradeLink can be null if we've passed the upgrade deadline
shouldDisplay: upgradeToCompleteGraded && verifiedUpgradeLink,
clickHandler: () => global.location.replace(verifiedUpgradeLink),
},
{
name: 'upgradeToResetBanner',
shouldDisplay: upgradeToReset,
clickHandler: () => window.location.replace(verifiedUpgradeLink),
// verifiedUpgradeLink can be null if we've passed the upgrade deadline
shouldDisplay: upgradeToReset && verifiedUpgradeLink,
clickHandler: () => global.location.replace(verifiedUpgradeLink),
},
{
name: 'resetDatesBanner',
shouldDisplay: resetDates,
clickHandler: () => dispatch(resetDeadlines(courseId, fetchDatesTab)),
clickHandler: () => dispatch(resetDeadlines(courseId, model, tabFetch)),
},
];
@@ -74,7 +72,20 @@ function DatesBannerContainer(props) {
}
DatesBannerContainer.propTypes = {
courseDateBlocks: PropTypes.arrayOf(PropTypes.object).isRequired,
datesBannerInfo: PropTypes.shape({
contentTypeGatingEnabled: PropTypes.bool.isRequired,
missedDeadlines: PropTypes.bool.isRequired,
missedGatedContent: PropTypes.bool.isRequired,
verifiedUpgradeLink: PropTypes.string,
}).isRequired,
hasEnded: PropTypes.bool,
model: PropTypes.string.isRequired,
tabFetch: PropTypes.func.isRequired,
};
DatesBannerContainer.defaultProps = {
hasEnded: false,
};
export default DatesBannerContainer;

View File

@@ -4,7 +4,10 @@ import classNames from 'classnames';
export default function Badge({ children, className }) {
return (
<span className={classNames('dates-badge badge align-text-bottom font-italic ml-2 px-2 py-1', className)}>
<span
className={classNames('dates-badge small ml-2', className)}
data-testid="dates-badge"
>
{children}
</span>
);

View File

@@ -1,3 +1,4 @@
.dates-badge {
font-size: 0.75rem;
border-radius: 4px;
padding: 2px 8px 3px 8px;
}

View File

@@ -1,17 +1,37 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import Timeline from './Timeline';
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
import { fetchDatesTab } from '../data';
import { useModel } from '../../generic/model-store';
function DatesTab({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
courseDateBlocks,
datesBannerInfo,
hasEnded,
} = useModel('dates', courseId);
return (
<>
<div role="heading" aria-level="1" className="h4 my-3">
<div role="heading" aria-level="1" className="h2 my-3">
{intl.formatMessage(messages.title)}
</div>
<DatesBannerContainer model="dates" />
<DatesBannerContainer
courseDateBlocks={courseDateBlocks}
datesBannerInfo={datesBannerInfo}
hasEnded={hasEnded}
model="dates"
tabFetch={fetchDatesTab}
/>
<Timeline />
</>
);

View File

@@ -0,0 +1,239 @@
import React from 'react';
import { Route } from 'react-router';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { waitForElementToBeRemoved } from '@testing-library/dom';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DatesTab from './DatesTab';
import { fetchDatesTab } from '../data';
import { fireEvent, initializeMockApp, waitFor } from '../../setupTest';
import initializeStore from '../../store';
import { TabContainer } from '../../tab-page';
import { UserMessagesProvider } from '../../generic/user-messages';
initializeMockApp();
describe('DatesTab', () => {
let axiosMock;
const store = initializeStore();
const component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Route path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</Route>
</UserMessagesProvider>
</AppProvider>
);
// The dates tab is largely repetitive non-interactive static data. Thus it's a little tough to follow
// testing-library's advice around testing the way your user uses the site (i.e. can't find form elements by label or
// anything). Instead, we find elements by printed date (which is what the user sees) and data-testid. Which is
// better than assuming anything about how the surrounding elements are organized by div and span or whatever. And
// better than adding non-style class names.
// Hence the following getDay query helper.
async function getDay(date) {
const dateNode = await screen.findByText(date);
let parent = dateNode.parentElement;
while (parent) {
if (parent.dataset && parent.dataset.testid === 'dates-day') {
return {
day: parent,
header: within(parent).getByTestId('dates-header'),
items: within(parent).queryAllByTestId('dates-item'),
};
}
parent = parent.parentElement;
}
throw new Error('Did not find day container');
}
describe('when receiving a full set of dates data', () => {
beforeEach(() => {
const datesTabData = Factory.build('datesTabData');
const courseMetadata = Factory.build('courseHomeMetadata');
const { courseId } = courseMetadata;
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`).reply(200, courseMetadata);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
render(component);
});
it('handles unreleased & complete', async () => {
const { header } = await getDay('Sun, May 3, 2020');
const badges = within(header).getAllByTestId('dates-badge');
expect(badges).toHaveLength(2);
expect(badges[0]).toHaveTextContent('Completed');
expect(badges[1]).toHaveTextContent('Not yet released');
});
it('handles unreleased & past due', async () => {
const { header } = await getDay('Mon, May 4, 2020');
const badges = within(header).getAllByTestId('dates-badge');
expect(badges).toHaveLength(2);
expect(badges[0]).toHaveTextContent('Past due');
expect(badges[1]).toHaveTextContent('Not yet released');
});
it('handles verified only', async () => {
const { day } = await getDay('Sun, Aug 18, 2030');
const badge = within(day).getByTestId('dates-badge');
expect(badge).toHaveTextContent('Verified only');
});
it('verified only has no link', async () => {
const { day } = await getDay('Sun, Aug 18, 2030');
expect(within(day).queryByRole('link')).toBeNull();
});
it('same status items have header badge', async () => {
const { day, header } = await getDay('Tue, May 26, 2020');
const badge = within(header).getByTestId('dates-badge');
expect(badge).toHaveTextContent('Past due'); // one header badge
expect(within(day).getAllByTestId('dates-badge')).toHaveLength(1); // no other badges
});
it('different status items have individual badges', async () => {
const { header, items } = await getDay('Thu, May 28, 2020');
const headerBadges = within(header).queryAllByTestId('dates-badge');
expect(headerBadges).toHaveLength(0); // no header badges
expect(items).toHaveLength(2);
expect(within(items[0]).getByTestId('dates-badge')).toHaveTextContent('Completed');
expect(within(items[1]).getByTestId('dates-badge')).toHaveTextContent('Past due');
});
it('shows extra info', async () => {
const { items } = await getDay('Sat, Aug 17, 2030');
expect(items).toHaveLength(3);
const tipIcon = within(items[2]).getByTestId('dates-extra-info');
const tipText = "ORA Dates are set by the instructor, and can't be changed";
expect(screen.queryByText(tipText)).toBeNull(); // tooltip does not start in DOM
userEvent.hover(tipIcon);
const tooltip = screen.getByText(tipText); // now it's there
userEvent.unhover(tipIcon);
waitForElementToBeRemoved(tooltip); // and it's gone again
});
});
describe('Dates banner container ', () => {
const courseMetadata = Factory.build('courseHomeMetadata', { is_self_paced: true, is_enrolled: true });
const { courseId } = courseMetadata;
const datesTabData = Factory.build('datesTabData');
beforeEach(() => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`).reply(200, courseMetadata);
history.push(`/course/${courseId}/dates`);
});
it('renders datesTabInfoBanner', async () => {
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: false,
missedDeadlines: false,
missedGatedContent: false,
};
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component);
await waitFor(() => expect(screen.getByText("We've built a suggested schedule to help you stay on track.")).toBeInTheDocument());
});
it('renders upgradeToCompleteGradedBanner', async () => {
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
missedDeadlines: false,
missedGatedContent: false,
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component);
await waitFor(() => expect(screen.getByText('You are auditing this course,')).toBeInTheDocument());
expect(screen.getByText('which means that you are unable to participate in graded assignments. To complete graded assignments as part of this course, you can upgrade today.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Upgrade now' })).toBeInTheDocument();
});
it('renders upgradeToResetBanner', async () => {
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
missedDeadlines: true,
missedGatedContent: true,
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component);
await waitFor(() => expect(screen.getByText('You are auditing this course,')).toBeInTheDocument());
expect(screen.getByText('which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument();
});
it('renders resetDatesBanner', async () => {
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
missedDeadlines: true,
missedGatedContent: false,
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component);
await waitFor(() => expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument());
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Dont worry—you wont lose any of the progress youve made when you shift your due dates.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Shift due dates' })).toBeInTheDocument();
});
it('handles shift due dates click', async () => {
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
missedDeadlines: true,
missedGatedContent: false,
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component);
// confirm "Shift due dates" button has rendered
await waitFor(() => expect(screen.getByRole('button', { name: 'Shift due dates' })).toBeInTheDocument());
// update response to reflect shifted dates
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
missedDeadlines: false,
missedGatedContent: false,
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
const resetDeadlinesData = {
header: "You've successfully shifted your dates!",
};
axiosMock.onPost(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`).reply(200, resetDeadlinesData);
// click "Shift due dates"
fireEvent.click(screen.getByRole('button', { name: 'Shift due dates' }));
// wait for page to reload & Toast to render
await waitFor(() => expect(screen.getByText("You've successfully shifted your dates!")).toBeInTheDocument());
// confirm "Shift due dates" button has not rendered
expect(screen.queryByRole('button', { name: 'Shift due dates' })).not.toBeInTheDocument();
});
});
});

View File

@@ -3,6 +3,9 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { FormattedDate, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Tooltip, OverlayTrigger } from '@edx/paragon';
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useModel } from '../../generic/model-store';
@@ -24,7 +27,7 @@ function Day({
const { color, badges } = getBadgeListAndColor(date, intl, null, items);
return (
<li className="dates-day pb-4">
<li className="dates-day pb-4" data-testid="dates-day">
{/* Top Line */}
{!first && <div className="dates-line-top border-1 border-left border-gray-900 bg-gray-900" />}
@@ -36,7 +39,7 @@ function Day({
{/* Content */}
<div className="d-inline-block ml-3 pl-2">
<div className="mb-1">
<div className="mb-1" data-testid="dates-header">
<p className="d-inline text-dark-500 font-weight-bold">
<FormattedDate
value={date}
@@ -56,15 +59,24 @@ function Day({
const available = item.learnerHasAccess && (item.link || !isLearnerAssignment(item));
const textColor = available ? 'text-dark-500' : 'text-dark-200';
return (
<div key={item.title + item.date} className={textColor}>
<div key={item.title + item.date} className={textColor} data-testid="dates-item">
<div>
<span className="font-weight-bold small mt-1">
{item.assignmentType && `${item.assignmentType}: `}{title}
</span>
{itemBadges}
{item.extraInfo && (
<OverlayTrigger
placement="bottom"
overlay={
<Tooltip>{item.extraInfo}</Tooltip>
}
>
<FontAwesomeIcon icon={faInfoCircle} className="fa-xs ml-1 text-gray-700" data-testid="dates-extra-info" />
</OverlayTrigger>
)}
</div>
{item.description && <div className="small mb-2">{item.description}</div>}
{item.extraInfo && <div className="small mb-2">{item.extraInfo}</div>}
</div>
);
})}

View File

@@ -37,19 +37,22 @@ function getBadgeListAndColor(date, intl, item, items) {
{
message: messages.today,
shownForDay: isToday,
bg: 'dates-bg-today',
bg: 'bg-warning-300',
className: 'text-gray-900',
},
{
message: messages.completed,
shownForDay: assignments.length && assignments.every(isComplete),
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
bg: 'bg-dark-100',
className: 'text-gray-900',
},
{
message: messages.pastDue,
shownForDay: assignments.length && assignments.every(isPastDue),
shownForItem: x => isLearnerAssignment(x) && isPastDue(x),
bg: 'bg-dark-200',
className: 'text-white',
},
{
message: messages.dueNext,
@@ -62,7 +65,7 @@ function getBadgeListAndColor(date, intl, item, items) {
message: messages.unreleased,
shownForDay: assignments.length && assignments.every(isUnreleased),
shownForItem: x => isLearnerAssignment(x) && isUnreleased(x),
className: 'border border-dark-200 text-gray-500 align-top',
className: 'border border-gray-500 text-gray-500',
},
{
message: messages.verifiedOnly,

View File

@@ -1,228 +0,0 @@
// Sample data helpful when developing, to see a variety of configurations.
// This set of data is not realistic (mix of having access and not), but it
// is intended to demonstrate many UI results.
// To use, have getDatesTabData in api.js return the result of this call instead:
/*
import fakeDatesData from '../dates-tab/fakeData';
export async function getDatesTabData(courseId, version) {
if (tab === 'dates') { return camelCaseObject(fakeDatesData()); }
...
}
*/
export default function fakeDatesData() {
return JSON.parse(`
{
"course_date_blocks": [
{
"date": "2020-05-01T17:59:41Z",
"date_type": "course-start-date",
"description": "",
"learner_has_access": true,
"link": "",
"title": "Course Starts",
"extra_info": null
},
{
"assignment_type": "Homework",
"complete": true,
"date": "2020-05-04T02:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"title": "Multi Badges Completed",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2020-05-05T02:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"title": "Multi Badges Past Due",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "Both Past Due 1",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "Both Past Due 2",
"extra_info": null
},
{
"assignment_type": "Homework",
"complete": true,
"date": "2020-05-28T08:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "One Completed/Due 1",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2020-05-28T08:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "One Completed/Due 2",
"extra_info": null
},
{
"assignment_type": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "Both Completed 1",
"extra_info": null
},
{
"assignment_type": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "Both Completed 2",
"extra_info": null
},
{
"date": "2020-06-16T17:59:40.942669Z",
"date_type": "verified-upgrade-deadline",
"description": "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
"learner_has_access": true,
"link": "https://example.com/",
"title": "Upgrade to Verified Certificate",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": false,
"link": "https://example.com/",
"title": "One Verified 1",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "One Verified 2",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "ORA Verified 2",
"extra_info": "ORA Dates are set by the instructor, and can't be changed"
},
{
"assignment_type": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": false,
"link": "https://example.com/",
"title": "Both Verified 1",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": false,
"link": "https://example.com/",
"title": "Both Verified 2",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"title": "One Unreleased 1"
},
{
"assignment_type": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "One Unreleased 2",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"title": "Both Unreleased 1",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"title": "Both Unreleased 2",
"extra_info": null
},
{
"date": "2030-08-23T00:00:00Z",
"date_type": "course-end-date",
"description": "",
"learner_has_access": true,
"link": "",
"title": "Course Ends",
"extra_info": null
},
{
"date": "2030-09-01T00:00:00Z",
"date_type": "verification-deadline-date",
"description": "You must successfully complete verification before this date to qualify for a Verified Certificate.",
"learner_has_access": false,
"link": "https://example.com/",
"title": "Verification Deadline",
"extra_info": null
}
],
"display_reset_dates_text": false,
"learner_is_verified": false,
"user_timezone": "America/New_York",
"verified_upgrade_link": "https://example.com/"
}
`);
}

View File

@@ -7,15 +7,15 @@ const messages = defineMessages({
},
dueNext: {
id: 'learning.dates.badge.dueNext',
defaultMessage: 'Due Next',
defaultMessage: 'Due next',
},
pastDue: {
id: 'learning.dates.badge.pastDue',
defaultMessage: 'Past Due',
defaultMessage: 'Past due',
},
title: {
id: 'learning.dates.title',
defaultMessage: 'Important Dates',
defaultMessage: 'Important dates',
},
today: {
id: 'learning.dates.badge.today',
@@ -23,11 +23,11 @@ const messages = defineMessages({
},
unreleased: {
id: 'learning.dates.badge.unreleased',
defaultMessage: 'Not Yet Released',
defaultMessage: 'Not yet released',
},
verifiedOnly: {
id: 'learning.dates.badge.verifiedOnly',
defaultMessage: 'Verified Only',
defaultMessage: 'Verified only',
},
});

View File

@@ -13,10 +13,10 @@ export default function DateSummary({
const linkedTitle = dateBlock.link && isLearnerAssignment(dateBlock);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<section className="container p-0 mb-3">
<li className="container p-0 mb-3 small text-dark-500">
<div className="row">
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" style={{ width: '20px' }} />
<div className="ml-2 font-weight-bold">
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" fixedWidth />
<div className="ml-1 font-weight-bold">
<FormattedDate
value={dateBlock.date}
day="numeric"
@@ -27,7 +27,7 @@ export default function DateSummary({
/>
</div>
</div>
<div className="row ml-4 px-2">
<div className="row ml-4 pr-2">
<div className="date-summary-text">
{linkedTitle
&& <div className="font-weight-bold mt-2"><a href={dateBlock.link}>{dateBlock.title}</a></div>}
@@ -35,12 +35,11 @@ export default function DateSummary({
&& <div className="font-weight-bold mt-2">{dateBlock.title}</div>}
</div>
{dateBlock.description
&& <div className="date-summary-text m-0 mt-1">{dateBlock.description}</div>}
&& <div className="date-summary-text mt-1">{dateBlock.description}</div>}
{!linkedTitle && dateBlock.link
&& <a href={dateBlock.link} className="description-link">{dateBlock.linkText}</a>}
</div>
</section>
</li>
);
}

View File

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

View File

@@ -3,16 +3,22 @@ import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
export default function LmsHtmlFragment({ html, title, ...rest }) {
export default function LmsHtmlFragment({
className,
html,
title,
...rest
}) {
const wholePage = `
<html>
<head>
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
<link rel="stylesheet" href="/static/css/bootstrap/lms-main.css">
</head>
<body>${html}</body>
</html>
`;
<html>
<head>
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
<link rel="stylesheet" href="/static/css/bootstrap/lms-main.css">
<link rel="stylesheet" type="text/css" href="${getConfig().BASE_URL}/src/course-home/outline-tab/LmsHtmlFragment.css">
</head>
<body class="${className}">${html}</body>
</html>
`;
const iframe = useRef(null);
function handleLoad() {
@@ -33,7 +39,12 @@ export default function LmsHtmlFragment({ html, title, ...rest }) {
);
}
LmsHtmlFragment.defaultProps = {
className: '',
};
LmsHtmlFragment.propTypes = {
className: PropTypes.string,
html: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
};

View File

@@ -1,24 +1,32 @@
import React from 'react';
import React, { useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { Button } from '@edx/paragon';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Toast } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates';
import CourseGoalCard from './widgets/CourseGoalCard';
import CourseHandouts from './widgets/CourseHandouts';
import CourseSock from '../../generic/course-sock';
import CourseTools from './widgets/CourseTools';
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
import { fetchOutlineTab } from '../data';
import genericMessages from '../../generic/messages';
import messages from './messages';
import Section from './Section';
import UpdateGoalSelector from './widgets/UpdateGoalSelector';
import UpgradeCard from './widgets/UpgradeCard';
import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
import useCertificateAvailableAlert from './alerts/certificate-available-alert';
import useCourseEndAlert from './alerts/course-end-alert';
import useCourseStartAlert from './alerts/course-start-alert';
import useEnrollmentAlert from '../../alerts/enrollment-alert';
import useLogistrationAlert from '../../alerts/logistration-alert';
import useOfferAlert from '../../alerts/offer-alert';
import usePrivateCourseAlert from './alerts/private-course-alert';
import { useModel } from '../../generic/model-store';
import WelcomeMessage from './widgets/WelcomeMessage';
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
function OutlineTab({ intl }) {
const {
@@ -26,55 +34,91 @@ function OutlineTab({ intl }) {
} = useSelector(state => state.courseHome);
const {
org,
title,
start,
end,
enrollmentStart,
enrollmentEnd,
enrollmentMode,
isEnrolled,
} = useModel('courses', courseId);
} = useModel('courseHomeMeta', courseId);
const {
accessExpiration,
canShowUpgradeSock,
courseBlocks: {
courses,
sections,
},
courseExpiredHtml,
offerHtml,
courseGoals: {
goalOptions,
selectedGoal,
},
datesBannerInfo,
datesWidget: {
courseDateBlocks,
userTimezone,
},
hasEnded,
resumeCourse: {
hasVisitedCourse,
url: resumeCourseUrl,
},
offer,
verifiedMode,
} = useModel('outline', courseId);
// Above the tab alerts (appearing in the order listed here)
const logistrationAlert = useLogistrationAlert();
const enrollmentAlert = useEnrollmentAlert(courseId);
const [courseGoalToDisplay, setCourseGoalToDisplay] = useState(selectedGoal);
const [goalToastHeader, setGoalToastHeader] = useState('');
const [expandAll, setExpandAll] = useState(false);
const logResumeCourseClick = () => {
sendTrackingLogEvent('edx.course.home.resume_course.clicked', {
courserun_key: courseId,
event_type: hasVisitedCourse ? 'resume' : 'start',
org_key: org,
url: resumeCourseUrl,
});
};
// Below the course title alerts (appearing in the order listed here)
const offerAlert = useOfferAlert(offerHtml, 'outline-course-alerts');
const accessExpirationAlert = useAccessExpirationAlert(courseExpiredHtml, 'outline-course-alerts');
const offerAlert = useOfferAlert(offer, userTimezone, 'outline-course-alerts');
const accessExpirationAlert = useAccessExpirationAlert(accessExpiration, userTimezone, 'outline-course-alerts');
const courseStartAlert = useCourseStartAlert(courseId);
const courseEndAlert = useCourseEndAlert(courseId);
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
const privateCourseAlert = usePrivateCourseAlert(courseId);
const rootCourseId = Object.keys(courses)[0];
const { sectionIds } = courses[rootCourseId];
const rootCourseId = courses && Object.keys(courses)[0];
const courseSock = useRef(null);
return (
<>
<AlertList
topic="outline"
className="mb-3"
customAlerts={{
...enrollmentAlert,
...logistrationAlert,
}}
/>
<div className="d-flex justify-content-between mb-3">
<h2>{title}</h2>
<Button className="btn-primary" type="button">{intl.formatMessage(messages.resume)}</Button>
<Toast
closeLabel={intl.formatMessage(genericMessages.close)}
onClose={() => setGoalToastHeader('')}
show={!!(goalToastHeader)}
>
{goalToastHeader}
</Toast>
<div className="row w-100 m-0 mb-3 justify-content-between">
<div className="col-12 col-sm-auto p-0">
<div role="heading" aria-level="1" className="h2">{title}</div>
</div>
{resumeCourseUrl && (
<div className="col-12 col-sm-auto p-0">
<Button block href={resumeCourseUrl} onClick={() => logResumeCourseClick()}>
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
</Button>
</div>
)}
</div>
<div className="row">
<div className="col col-8">
<WelcomeMessage courseId={courseId} />
<div className="col-12">
<AlertList
topic="outline-private-alerts"
customAlerts={{
...privateCourseAlert,
}}
/>
</div>
<div className="col col-12 col-md-8">
<AlertList
topic="outline-course-alerts"
className="mb-3"
@@ -86,33 +130,88 @@ function OutlineTab({ intl }) {
...offerAlert,
}}
/>
{sectionIds.map((sectionId) => (
<Section
key={sectionId}
courseId={courseId}
title={sections[sectionId].title}
sequenceIds={sections[sectionId].sequenceIds}
{courseDateBlocks && (
<DatesBannerContainer
courseDateBlocks={courseDateBlocks}
datesBannerInfo={datesBannerInfo}
hasEnded={hasEnded}
model="outline"
tabFetch={fetchOutlineTab}
/>
))}
</div>
<div className="col col-4">
<CourseTools
courseId={courseId}
/>
<CourseDates
start={start}
end={end}
enrollmentStart={enrollmentStart}
enrollmentEnd={enrollmentEnd}
enrollmentMode={enrollmentMode}
isEnrolled={isEnrolled}
courseId={courseId}
/>
<CourseHandouts
courseId={courseId}
/>
)}
{!courseGoalToDisplay && goalOptions.length > 0 && (
<CourseGoalCard
courseId={courseId}
goalOptions={goalOptions}
title={title}
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
/>
)}
<WelcomeMessage courseId={courseId} />
{rootCourseId && (
<>
<div className="row w-100 m-0 mb-3 justify-content-end">
<div className="col-12 col-sm-auto p-0">
<Button variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
{expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)}
</Button>
</div>
</div>
<ol className="list-unstyled">
{courses[rootCourseId].sectionIds.map((sectionId) => (
<Section
key={sectionId}
courseId={courseId}
defaultOpen={sections[sectionId].resumeBlock}
expand={expandAll}
section={sections[sectionId]}
/>
))}
</ol>
</>
)}
</div>
{rootCourseId && (
<div className="col col-12 col-md-4">
<ProctoringInfoPanel
courseId={courseId}
/>
{courseGoalToDisplay && goalOptions.length > 0 && (
<UpdateGoalSelector
courseId={courseId}
goalOptions={goalOptions}
selectedGoal={courseGoalToDisplay}
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
/>
)}
<CourseTools
courseId={courseId}
/>
<UpgradeCard
courseId={courseId}
onLearnMore={canShowUpgradeSock ? () => { courseSock.current.showToUser(); } : null}
/>
<CourseDates
courseId={courseId}
/>
<CourseHandouts
courseId={courseId}
/>
</div>
)}
</div>
{canShowUpgradeSock && (
<CourseSock
courseId={courseId}
offer={offer}
orgKey={org}
pageLocation="Home Page"
ref={courseSock}
verifiedMode={verifiedMode}
/>
)}
</>
);
}

View File

@@ -0,0 +1,537 @@
import React from 'react';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import userEvent from '@testing-library/user-event';
import buildSimpleCourseBlocks from '../data/__factories__/courseBlocks.factory';
import {
fireEvent, initializeMockApp, logUnhandledRequests, render, screen, waitFor, act,
} from '../../setupTest';
import executeThunk from '../../utils';
import * as thunks from '../data/thunks';
import initializeStore from '../../store';
import OutlineTab from './OutlineTab';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
describe('Outline Tab', () => {
let axiosMock;
const courseId = 'course-v1:edX+Test+run';
const courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
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 proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
const store = initializeStore();
const defaultMetadata = Factory.build('courseHomeMetadata', { courseId });
const defaultTabData = Factory.build('outlineTabData');
function setMetadata(attributes, options) {
const courseMetadata = Factory.build('courseHomeMetadata', { courseId, ...attributes }, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
}
function setTabData(attributes, options) {
const outlineTabData = Factory.build('outlineTabData', attributes, options);
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
}
async function fetchAndRender() {
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render(<OutlineTab />, { store }));
}
beforeEach(async () => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
// Set defaults for network requests
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onPost(enrollmentUrl).reply(200, {});
axiosMock.onPost(goalUrl).reply(200, { header: 'Success' });
axiosMock.onGet(outlineUrl).reply(200, defaultTabData);
axiosMock.onGet(proctoringInfoUrl).reply(200, { onboarding_status: 'created', onboarding_link: 'test' });
logUnhandledRequests(axiosMock);
});
describe('Course Outline', () => {
it('displays link to start course', async () => {
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
});
it('displays link to resume course', async () => {
setTabData({
resume_course: {
has_visited_course: true,
url: `${getConfig().LMS_BASE_URL}/courses/${courseId}/jump_to/block-v1:edX+Test+Block@12345abcde`,
},
});
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Resume course' })).toBeInTheDocument();
});
it('expands section that contains resume block', async () => {
const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { resumeBlock: true });
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
const expandedSectionNode = screen.getByRole('button', { name: /Title of Section/ });
expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true');
});
it('handles expand/collapse all button click', async () => {
await fetchAndRender();
// Button renders as "Expand All"
const expandButton = screen.getByRole('button', { name: 'Expand all' });
expect(expandButton).toBeInTheDocument();
// Section initially renders collapsed
const collapsedSectionNode = screen.getByRole('button', { name: /section/ });
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
// Click to expand section
userEvent.click(expandButton);
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
// Click to collapse section
userEvent.click(expandButton);
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
});
it('displays correct icon for complete assignment', async () => {
const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { complete: true });
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
expect(screen.getByTitle('Completed section')).toBeInTheDocument();
});
it('displays correct icon for incomplete assignment', async () => {
const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { complete: false });
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
});
});
describe('Welcome Message', () => {
beforeEach(() => {
setMetadata({ is_enrolled: true });
});
it('does not render show more/less button under 100 words', async () => {
await fetchAndRender();
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument();
});
describe('over 100 words', () => {
beforeEach(async () => {
setTabData({
welcome_message_html: '<p>'
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
+ '</p>',
});
await fetchAndRender();
});
it('shortens message', async () => {
expect(screen.getByTestId('short-welcome-message-iframe')).toBeInTheDocument();
const showMoreButton = screen.queryByRole('button', { name: 'Show More' });
expect(showMoreButton).toBeInTheDocument();
});
it('renders show more/less button and handles click', async () => {
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
let showMoreButton = screen.getByRole('button', { name: 'Show More' });
expect(showMoreButton).toBeInTheDocument();
userEvent.click(showMoreButton);
let showLessButton = screen.getByRole('button', { name: 'Show Less' });
expect(showLessButton).toBeInTheDocument();
expect(screen.getByTestId('long-welcome-message-iframe')).toBeInTheDocument();
userEvent.click(showLessButton);
showLessButton = screen.queryByRole('button', { name: 'Show Less' });
expect(showLessButton).not.toBeInTheDocument();
showMoreButton = screen.getByRole('button', { name: 'Show More' });
expect(showMoreButton).toBeInTheDocument();
});
});
it('does not display if no update available', async () => {
setTabData({ welcome_message_html: null });
await fetchAndRender();
expect(screen.queryByTestId('alert-container-welcome')).not.toBeInTheDocument();
});
});
describe('Course Goals', () => {
const goalOptions = [
['certify', 'Earn a certificate'],
['complete', 'Complete the course'],
['explore', 'Explore the course'],
['unsure', 'Not sure yet'],
];
it('does not render goal widgets if no goals available', async () => {
await fetchAndRender();
expect(screen.queryByTestId('course-goal-card')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
expect(screen.queryByTestId('edit-goal-selector')).not.toBeInTheDocument();
});
describe('goal is not set', () => {
beforeEach(async () => {
setTabData({
course_goals: {
goal_options: goalOptions,
selected_goal: null,
},
});
await fetchAndRender();
});
it('renders goal card', () => {
expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
expect(screen.getByTestId('course-goal-card')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Complete the course' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Explore the course' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Not sure yet' })).toBeInTheDocument();
});
it('renders goal selector on goal selection', async () => {
const certifyGoalButton = screen.getByRole('button', { name: 'Earn a certificate' });
fireEvent.click(certifyGoalButton);
const goalSelector = await screen.findByTestId('edit-goal-selector');
expect(goalSelector).toBeInTheDocument();
});
});
describe('goal is set', () => {
beforeEach(async () => {
setTabData({
course_goals: {
goal_options: goalOptions,
selected_goal: { text: 'Earn a certificate', key: 'certify' },
},
});
await fetchAndRender();
});
it('renders edit goal selector', () => {
expect(screen.getByLabelText('Goal')).toBeInTheDocument();
expect(screen.getByTestId('edit-goal-selector')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
});
it('updates goal on click', async () => {
// Open dropdown
const dropdownButtonNode = screen.getByRole('button', { name: 'Earn a certificate' });
await waitFor(() => {
expect(dropdownButtonNode).toBeInTheDocument();
});
fireEvent.click(dropdownButtonNode);
// Select a new goal
const unsureButtonNode = screen.getByRole('button', { name: 'Not sure yet' });
await waitFor(() => {
expect(unsureButtonNode).toBeInTheDocument();
});
fireEvent.click(unsureButtonNode);
// Verify the request was made
await waitFor(() => {
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","goal_key":"unsure"}`);
});
});
});
});
describe('Course Handouts', () => {
it('renders title when handouts are available', async () => {
await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Course Handouts' })).toBeInTheDocument();
});
it('does not display title if no handouts available', async () => {
setTabData({ handouts_html: null });
await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Course Handouts' })).not.toBeInTheDocument();
});
});
describe('Alert List', () => {
describe('Private Course Alert', () => {
it('does not display alert for enrolled user', async () => {
setMetadata({ is_enrolled: true });
await fetchAndRender();
expect(screen.queryByRole('button', { name: 'Enroll now' })).not.toBeInTheDocument();
expect(screen.queryByText('to access the full course')).not.toBeInTheDocument();
});
it('does not display enrollment button if enrollment is not available', async () => {
setTabData({
enroll_alert: {
can_enroll: false,
},
});
await fetchAndRender();
const alert = await screen.findByText('Welcome to Demonstration Course');
expect(alert.parentElement).toHaveAttribute('role', 'alert');
expect(screen.queryByRole('button', { name: 'Enroll now' })).not.toBeInTheDocument();
expect(screen.getByText('You must be enrolled in the course to see course content.')).toBeInTheDocument();
});
it('displays alert for unenrolled user', async () => {
await fetchAndRender();
const alert = await screen.findByText('Welcome to Demonstration Course');
expect(alert.parentElement).toHaveAttribute('role', 'alert');
expect(screen.getByRole('button', { name: 'Enroll now' })).toBeInTheDocument();
});
it('handles button click', async () => {
const { location } = window;
delete window.location;
window.location = {
reload: jest.fn(),
};
await fetchAndRender();
const button = await screen.findByRole('button', { name: 'Enroll now' });
fireEvent.click(button);
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
expect(axiosMock.history.post[0].data)
.toEqual(JSON.stringify({ course_details: { course_id: courseId } }));
expect(window.location.reload).toHaveBeenCalledTimes(1);
window.location = location;
});
});
describe('Access Expiration Alert', () => {
it('has special masquerade text', async () => {
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: true,
upgrade_deadline: null,
upgrade_url: null,
},
});
await fetchAndRender();
await screen.findByText('This learner does not have access to this course.', { exact: false });
});
it('shows expiration', async () => {
setTabData({
access_expiration: {
expiration_date: '2080-01-01T12:00:00Z',
masquerading_expired_course: false,
upgrade_deadline: null,
upgrade_url: null,
},
});
await fetchAndRender();
await screen.findByText('Audit Access Expires');
});
it('shows upgrade prompt', async () => {
setTabData({
access_expiration: {
expiration_date: '2080-01-01T12:00:00Z',
masquerading_expired_course: false,
upgrade_deadline: '2070-01-01T12:00:00Z',
upgrade_url: 'https://example.com/upgrade',
},
});
await fetchAndRender();
await screen.findByText('to get unlimited access to the course as long as it exists on the site.', { exact: false });
});
});
describe('Course Start Alert', () => {
// Only appears if enrolled and before start of course
it('appears several days out', async () => {
const startDate = new Date();
startDate.setDate(startDate.getDate() + 100);
setMetadata({ is_enrolled: true });
setTabData({}, {
dateBlocks: [
{
date_type: 'course-start-date',
date: startDate.toISOString(),
title: 'Start',
},
],
});
await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
});
it('appears today', async () => {
const startDate = new Date();
startDate.setHours(startDate.getHours() + 1);
setMetadata({ is_enrolled: true });
setTabData({}, {
dateBlocks: [
{
date_type: 'course-start-date',
date: startDate.toISOString(),
title: 'Start',
},
],
});
await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date
});
});
describe('Course End Alert', () => {
// Only appears if enrolled and within 14 days before the end of course
it('appears several days out', async () => {
const endDate = new Date();
endDate.setDate(endDate.getDate() + 13);
setMetadata({ is_enrolled: true });
setTabData({}, {
dateBlocks: [
{
date_type: 'course-end-date',
date: endDate.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
const node = await screen.findByText('This course is ending', { exact: false });
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
});
it('appears today', async () => {
const endDate = new Date();
endDate.setHours(endDate.getHours() + 1);
setMetadata({ is_enrolled: true });
setTabData({}, {
dateBlocks: [
{
date_type: 'course-end-date',
date: endDate.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
const node = await screen.findByText('This course is ending', { exact: false });
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date
});
});
describe('Certificate Available Alert', () => {
// Must satisfy two conditions for alert to appear: enrolled and between course end and cert availability
it('appears', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({}, {
dateBlocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
],
});
await fetchAndRender();
await screen.findByText('We are working on generating course certificates.');
});
});
});
describe('Proctoring Info Panel', () => {
it('appears', async () => {
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
});
it('appears for verified', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(200, { onboarding_status: 'verified', onboarding_link: 'test' });
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByRole('link', { name: 'Complete Onboarding' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
expect(screen.queryByText('You must complete the onboarding process prior to taking any proctored exam.')).not.toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).not.toBeInTheDocument();
});
it('appears for rejected', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(200, { onboarding_status: 'rejected', onboarding_link: 'test' });
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByRole('link', { name: 'Complete Onboarding' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
expect(screen.queryByText('You must complete the onboarding process prior to taking any proctored exam.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).toBeInTheDocument();
});
it('appears for submitted', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(200, { onboarding_status: 'submitted', onboarding_link: 'test' });
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your submitted profile is in review.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).toBeInTheDocument();
});
it('appears for second_review_required', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(200, { onboarding_status: 'second_review_required', onboarding_link: 'test' });
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your submitted profile is in review.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).toBeInTheDocument();
});
it('appears for no status', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(200, { onboarding_status: '', onboarding_link: 'test' });
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByRole('link', { name: 'Complete Onboarding' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
expect(screen.queryByText('You must complete the onboarding process prior to taking any proctored exam.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).toBeInTheDocument();
});
it('does not appear for 404', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(404);
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).not.toBeInTheDocument();
});
});
});

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useAlert } from '../../../../generic/user-messages';
@@ -9,28 +9,30 @@ const CertificateAvailableAlert = React.lazy(() => import('./CertificateAvailabl
function useCertificateAvailableAlert(courseId) {
const {
isEnrolled,
} = useModel('courses', courseId);
} = useModel('courseHomeMeta', courseId);
const {
datesWidget: {
courseDateBlocks,
userTimezone,
},
} = useModel('outline', courseId);
const { username } = getAuthenticatedUser();
const authenticatedUser = getAuthenticatedUser();
const username = authenticatedUser ? authenticatedUser.username : '';
const certBlock = courseDateBlocks.find(b => b.dateType === 'certificate-available-date');
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
const endDate = endBlock ? new Date(endBlock.date) : null;
const hasEnded = endBlock ? endDate < new Date() : false;
const isVisible = isEnrolled && certBlock && hasEnded; // only show if we're between end and cert dates
const payload = {
certDate: certBlock && certBlock.date,
username,
userTimezone,
};
useAlert(isVisible, {
code: 'clientCertificateAvailableAlert',
payload: {
certDate: certBlock && certBlock.date,
username,
userTimezone,
},
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',
});

View File

@@ -13,7 +13,6 @@ const DAY_MS = 24 * 60 * 60 * 1000; // in ms
function CourseEndAlert({ payload }) {
const {
delta,
description,
endDate,
userTimezone,
@@ -30,6 +29,7 @@ function CourseEndAlert({ payload }) {
);
let msg;
const delta = new Date(endDate) - new Date();
if (delta < DAY_MS) {
const courseEndTime = (
<FormattedTime
@@ -88,7 +88,6 @@ function CourseEndAlert({ payload }) {
CourseEndAlert.propTypes = {
payload: PropTypes.shape({
delta: PropTypes.number,
description: PropTypes.string,
endDate: PropTypes.string,
userTimezone: PropTypes.string,

View File

@@ -1,5 +1,5 @@
/* eslint-disable import/prefer-default-export */
import React from 'react';
import React, { useMemo } from 'react';
import { useAlert } from '../../../../generic/user-messages';
import { useModel } from '../../../../generic/model-store';
@@ -11,7 +11,7 @@ const WARNING_PERIOD_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
export function useCourseEndAlert(courseId) {
const {
isEnrolled,
} = useModel('courses', courseId);
} = useModel('courseHomeMeta', courseId);
const {
datesWidget: {
courseDateBlocks,
@@ -23,15 +23,15 @@ export function useCourseEndAlert(courseId) {
const endDate = endBlock ? new Date(endBlock.date) : null;
const delta = endBlock ? endDate - new Date() : 0;
const isVisible = isEnrolled && endBlock && delta > 0 && delta < WARNING_PERIOD_MS;
const payload = {
description: endBlock && endBlock.description,
endDate: endBlock && endBlock.date,
userTimezone,
};
useAlert(isVisible, {
code: 'clientCourseEndAlert',
payload: {
delta,
description: endBlock && endBlock.description,
endDate: endBlock && endBlock.date,
userTimezone,
},
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',
});

View File

@@ -13,7 +13,6 @@ const DAY_MS = 24 * 60 * 60 * 1000; // in ms
function CourseStartAlert({ payload }) {
const {
delta,
startDate,
userTimezone,
} = payload;
@@ -28,6 +27,7 @@ function CourseStartAlert({ payload }) {
/>
);
const delta = new Date(startDate) - new Date();
if (delta < DAY_MS) {
return (
<Alert type={ALERT_TYPES.INFO}>
@@ -88,7 +88,6 @@ function CourseStartAlert({ payload }) {
CourseStartAlert.propTypes = {
payload: PropTypes.shape({
delta: PropTypes.number,
startDate: PropTypes.string,
userTimezone: PropTypes.string,
}).isRequired,

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useAlert } from '../../../../generic/user-messages';
import { useModel } from '../../../../generic/model-store';
@@ -7,7 +7,7 @@ const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
function useCourseStartAlert(courseId) {
const {
isEnrolled,
} = useModel('courses', courseId);
} = useModel('courseHomeMeta', courseId);
const {
datesWidget: {
courseDateBlocks,
@@ -18,14 +18,14 @@ function useCourseStartAlert(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: {
delta,
startDate: startBlock && startBlock.date,
userTimezone,
},
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',
});

View File

@@ -0,0 +1,117 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { Button, Hyperlink } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { Alert } from '../../../../generic/user-messages';
import enrollmentMessages from '../../../../alerts/enrollment-alert/messages';
import genericMessages from '../../../../generic/messages';
import outlineMessages from '../../messages';
import { useEnrollClickHandler } from '../../../../alerts/enrollment-alert/hooks';
import { useModel } from '../../../../generic/model-store';
function PrivateCourseAlert({ intl, payload }) {
const {
anonymousUser,
canEnroll,
courseId,
} = payload;
const {
org,
title,
} = useModel('courseHomeMeta', courseId);
const { enrollClickHandler, loading } = useEnrollClickHandler(
courseId,
org,
intl.formatMessage(enrollmentMessages.success),
);
const enrollNow = (
<Button
disabled={loading}
variant="link"
className="p-0 border-0 align-top"
style={{ textDecoration: 'underline' }}
onClick={enrollClickHandler}
>
{intl.formatMessage(enrollmentMessages.enrollNowInline)}
</Button>
);
const register = (
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
>
{intl.formatMessage(genericMessages.registerLowercase)}
</Hyperlink>
);
const signIn = (
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={`${getLoginRedirectUrl(global.location.href)}`}
>
{intl.formatMessage(genericMessages.signInSentenceCase)}
</Hyperlink>
);
return (
<Alert type="welcome">
{anonymousUser && (
<>
<p className="font-weight-bold">
{intl.formatMessage(enrollmentMessages.alert)}
</p>
<FormattedMessage
id="learning.privateCourse.signInOrRegister"
description="Prompts the user to sign in or register to see course content."
defaultMessage="{signIn} or {register} and then enroll in this course."
values={{
signIn,
register,
}}
/>
</>
)}
{!anonymousUser && (
<>
<p className="font-weight-bold">{intl.formatMessage(outlineMessages.welcomeTo)} {title}</p>
{canEnroll && (
<>
<FormattedMessage
id="learning.privateCourse.canEnroll"
description="Prompts the user to enroll in the course to see course content."
defaultMessage="{enrollNow} to access the full course."
values={{ enrollNow }}
/>
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
</>
)}
{!canEnroll && (
<>
{intl.formatMessage(enrollmentMessages.alert)}
</>
)}
</>
)}
</Alert>
);
}
PrivateCourseAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
anonymousUser: PropTypes.bool,
canEnroll: PropTypes.bool,
courseId: PropTypes.string,
}).isRequired,
};
export default injectIntl(PrivateCourseAlert);

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
enroll: {
id: 'alert.enroll',
defaultMessage: 'You must be enrolled in the course to see course content.',
description: 'Text instructing the learner to enroll in the course in order to see course content.',
},
});
export default messages;

View File

@@ -5,22 +5,96 @@ const messages = defineMessages({
id: 'learning.outline.dates.all',
defaultMessage: 'View all course dates',
},
collapseAll: {
id: 'learning.outline.collapseAll',
defaultMessage: 'Collapse all',
description: 'Label for button to close all of the collapsible sections',
},
completedAssignment: {
id: 'learning.outline.completedAssignment',
defaultMessage: 'Completed',
description: 'Text used to describe the green checkmark icon in front of an assignment title',
},
completedSection: {
id: 'learning.outline.completedSection',
defaultMessage: 'Completed section',
description: 'Text used to describe the green checkmark icon in front of a section title',
},
dates: {
id: 'learning.outline.dates',
defaultMessage: 'Upcoming Dates',
},
editGoal: {
id: 'learning.outline.editGoal',
defaultMessage: 'Edit goal',
description: 'Edit course goal button',
},
expandAll: {
id: 'learning.outline.expandAll',
defaultMessage: 'Expand all',
description: 'Label for button to open all of the collapsible sections',
},
goal: {
id: 'learning.outline.goal',
defaultMessage: 'Goal',
description: 'Label for the selected course goal',
},
goalUnsure: {
id: 'learning.outline.goalUnsure',
defaultMessage: 'Not sure yet',
},
handouts: {
id: 'learning.outline.handouts',
defaultMessage: 'Course Handouts',
},
incompleteAssignment: {
id: 'learning.outline.incompleteAssignment',
defaultMessage: 'Incomplete',
description: 'Text used to describe the gray checkmark icon in front of an assignment title',
},
incompleteSection: {
id: 'learning.outline.incompleteSection',
defaultMessage: 'Incomplete section',
description: 'Text used to describe the gray checkmark icon in front of a section title',
},
learnMore: {
id: 'learning.outline.learnMore',
defaultMessage: 'Learn More',
},
openSection: {
id: 'learning.outline.altText.openSection',
defaultMessage: 'Open',
description: 'A button to open the given section of the course outline',
},
resume: {
id: 'learning.outline.resume',
defaultMessage: 'Resume Course',
defaultMessage: 'Resume course',
},
setGoal: {
id: 'learning.outline.setGoal',
defaultMessage: 'To start, set a course goal by selecting the option below that best describes your learning plan.',
},
start: {
id: 'learning.outline.start',
defaultMessage: 'Start Course',
},
tools: {
id: 'learning.outline.tools',
defaultMessage: 'Course Tools',
},
upgradeButton: {
id: 'learning.outline.upgradeButton',
defaultMessage: 'Upgrade ({symbol}{price})',
},
upgradeTitle: {
id: 'learning.outline.upgradeTitle',
defaultMessage: 'Pursue a verified certificate',
},
certAlt: {
id: 'learning.outline.certificateAlt',
defaultMessage: 'Example Certificate',
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
},
welcomeMessage: {
id: 'learning.outline.welcomeMessage',
defaultMessage: 'Welcome Message',
@@ -33,6 +107,87 @@ const messages = defineMessages({
id: 'learning.outline.welcomeMessageShowLessButton',
defaultMessage: 'Show Less',
},
welcomeTo: {
id: 'learning.outline.goalWelcome',
defaultMessage: 'Welcome to',
description: 'This precedes the title of the course',
},
proctoringInfoPanel: {
id: 'learning.proctoringPanel.header',
defaultMessage: 'This course contains proctored exams',
},
notStartedProctoringStatus: {
id: 'learning.proctoringPanel.status.notStarted',
defaultMessage: 'Not Started',
},
startedProctoringStatus: {
id: 'learning.proctoringPanel.status.started',
defaultMessage: 'Started',
},
submittedProctoringStatus: {
id: 'learning.proctoringPanel.status.submitted',
defaultMessage: 'Submitted',
},
verifiedProctoringStatus: {
id: 'learning.proctoringPanel.status.verified',
defaultMessage: 'Verified',
},
rejectedProctoringStatus: {
id: 'learning.proctoringPanel.status.rejected',
defaultMessage: 'Rejected',
},
errorProctoringStatus: {
id: 'learning.proctoringPanel.status.error',
defaultMessage: 'Error',
},
proctoringCurrentStatus: {
id: 'learning.proctoringPanel.status',
defaultMessage: 'Current Onboarding Status:',
},
notStartedProctoringMessage: {
id: 'learning.proctoringPanel.message.notStarted',
defaultMessage: 'You have not started your onboarding exam.',
},
startedProctoringMessage: {
id: 'learning.proctoringPanel.message.started',
defaultMessage: 'You have started your onboarding exam.',
},
submittedProctoringMessage: {
id: 'learning.proctoringPanel.message.submitted',
defaultMessage: 'You have submitted your onboarding exam.',
},
verifiedProctoringMessage: {
id: 'learning.proctoringPanel.message.verified',
defaultMessage: 'You can now take proctored exams in this course.',
},
rejectedProctoringMessage: {
id: 'learning.proctoringPanel.message.rejected',
defaultMessage: 'Your onboarding exam has been rejected. Please retry onboarding.',
},
errorProctoringMessage: {
id: 'learning.proctoringPanel.message.error',
defaultMessage: 'An error has occurred during your onboarding exam. Please retry onboarding.',
},
proctoringPanelGeneralInfo: {
id: 'learning.proctoringPanel.generalInfo',
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ',
},
proctoringPanelGeneralInfoSubmitted: {
id: 'learning.proctoringPanel.generalInfoSubmitted',
defaultMessage: 'Your submitted profile is in review.',
},
proctoringPanelGeneralTime: {
id: 'learning.proctoringPanel.generalTime',
defaultMessage: 'Onboarding profile review, including identity verification, can take 2+ business days.',
},
proctoringOnboardingButton: {
id: 'learning.proctoringPanel.onboardingButton',
defaultMessage: 'Complete Onboarding',
},
proctoringReviewRequirementsButton: {
id: 'learning.proctoringPanel.reviewRequirementsButton',
defaultMessage: 'Review instructions and system requirements',
},
});
export default messages;

View File

@@ -9,20 +9,30 @@ import { useModel } from '../../../generic/model-store';
function CourseDates({ courseId, intl }) {
const {
datesWidget,
datesWidget: {
courseDateBlocks,
datesTabLink,
userTimezone,
},
} = useModel('outline', courseId);
if (courseDateBlocks.length === 0) {
return null;
}
return (
<section className="mb-3">
<h4>{intl.formatMessage(messages.dates)}</h4>
{datesWidget.courseDateBlocks.map((courseDateBlock) => (
<DateSummary
key={courseDateBlock.title + courseDateBlock.date}
dateBlock={courseDateBlock}
userTimezone={datesWidget.userTimezone}
/>
))}
<a className="font-weight-bold" href={datesWidget.datesTabLink}>
<section className="mb-4">
<h2 className="h4">{intl.formatMessage(messages.dates)}</h2>
<ol className="list-unstyled">
{courseDateBlocks.map((courseDateBlock) => (
<DateSummary
key={courseDateBlock.title + courseDateBlock.date}
dateBlock={courseDateBlock}
userTimezone={userTimezone}
/>
))}
</ol>
<a className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
{intl.formatMessage(messages.allDates)}
</a>
</section>

View File

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

View File

@@ -17,9 +17,10 @@ function CourseHandouts({ courseId, intl }) {
}
return (
<section className="mb-3">
<h4>{intl.formatMessage(messages.handouts)}</h4>
<section className="mb-4">
<h2 className="h4">{intl.formatMessage(messages.handouts)}</h2>
<LmsHtmlFragment
className="small"
html={handoutsHtml}
title={intl.formatMessage(messages.handouts)}
/>

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { sendTrackEvent } 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';
@@ -14,14 +14,21 @@ import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function CourseTools({ courseId, intl }) {
const { org } = useModel('courseHomeMeta', courseId);
const {
courseTools,
} = useModel('outline', courseId);
if (courseTools.length === 0) {
return null;
}
const logClick = (analyticsId) => {
const { administrator } = getAuthenticatedUser();
sendTrackEvent('edx.course.tool.accessed', {
course_id: courseId,
sendTrackingLogEvent('edx.course.tool.accessed', {
org_key: org,
courserun_key: courseId,
course_id: courseId, // should only be courserun_key, but left as-is for historical reasons
is_staff: administrator,
tool_name: analyticsId,
});
@@ -47,16 +54,18 @@ function CourseTools({ courseId, intl }) {
};
return (
<section className="mb-3">
<h4>{intl.formatMessage(messages.tools)}</h4>
{courseTools.map((courseTool) => (
<div key={courseTool.analyticsId}>
<a href={courseTool.url} onClick={() => logClick(courseTool.analyticsId)}>
<FontAwesomeIcon icon={renderIcon(courseTool.analyticsId)} className="mr-2" style={{ width: '20px' }} />
{courseTool.title}
</a>
</div>
))}
<section className="mb-4">
<h2 className="h4">{intl.formatMessage(messages.tools)}</h2>
<ul className="list-unstyled">
{courseTools.map((courseTool) => (
<li key={courseTool.analyticsId} className="small">
<a href={courseTool.url} onClick={() => logClick(courseTool.analyticsId)}>
<FontAwesomeIcon icon={renderIcon(courseTool.analyticsId)} className="mr-2" fixedWidth />
{courseTool.title}
</a>
</li>
))}
</ul>
</section>
);
}

View File

@@ -0,0 +1,111 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import messages from '../messages';
import { getProctoringInfoData } from '../../data/api';
function ProctoringInfoPanel({ courseId, intl }) {
const [status, setStatus] = useState('');
const [link, setLink] = useState('');
const [readableStatus, setReadableStatus] = useState('');
function getReadableStatusClass(examStatus) {
let readableClass = '';
if (['created', 'download_software_clicked', 'ready_to_start'].includes(examStatus) || !examStatus) {
readableClass = 'notStarted';
} else if (['started', 'ready_to_submit'].includes(examStatus)) {
readableClass = 'started';
} else if (['second_review_required', 'submitted'].includes(examStatus)) {
readableClass = 'submitted';
} else if (['verified', 'rejected', 'error'].includes(examStatus)) {
readableClass = examStatus;
}
return readableClass;
}
function isNotYetSubmitted(examStatus) {
const NO_SHOW_STATES = ['submitted', 'second_review_required', 'verified'];
return !NO_SHOW_STATES.includes(examStatus);
}
function getBorderClass(examStatus) {
let borderClass = '';
if (['submitted', 'second_review_required'].includes(examStatus)) {
borderClass = 'proctoring-onboarding-submitted';
} else if (examStatus === 'verified') {
borderClass = 'proctoring-onboarding-success';
}
return borderClass;
}
useEffect(() => {
getProctoringInfoData(courseId)
.then(
response => {
if (response) {
setStatus(response.onboarding_status);
setLink(response.onboarding_link);
setReadableStatus(getReadableStatusClass(response.onboarding_status));
}
},
);
}, []);
return (
<>
{ link && (
<section className={`mb-4 p-3 outline-sidebar-proctoring-panel ${getBorderClass(status)}`}>
<h2 className="h4" id="outline-sidebar-upgrade-header">{intl.formatMessage(messages.proctoringInfoPanel)}</h2>
<div>
{readableStatus && (
<>
<p className="h6">
{intl.formatMessage(messages.proctoringCurrentStatus)} {intl.formatMessage(messages[`${readableStatus}ProctoringStatus`])}
</p>
<p>
{intl.formatMessage(messages[`${readableStatus}ProctoringMessage`])}
</p>
</>
)}
{(readableStatus !== 'verified') && (
<>
<p>
{isNotYetSubmitted(status) && (
<>
{intl.formatMessage(messages.proctoringPanelGeneralInfo)}
</>
)}
{!isNotYetSubmitted(status) && (
<>
{intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)}
</>
)}
</p>
<p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p>
</>
)}
{isNotYetSubmitted(status) && (
<Button variant="primary" block href={`${getConfig().LMS_BASE_URL}${link}`}>
{intl.formatMessage(messages.proctoringOnboardingButton)}
</Button>
)}
<Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams">
{intl.formatMessage(messages.proctoringReviewRequirementsButton)}
</Button>
</div>
</section>
)}
</>
);
}
ProctoringInfoPanel.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(ProctoringInfoPanel);

View File

@@ -0,0 +1,10 @@
.outline-sidebar-proctoring-panel {
border: 1px solid $dark-500;
border-top: 5px solid $brand-600;
}
.proctoring-onboarding-success {
border-top: 5px solid $primary-500;
}
.proctoring-onboarding-submitted {
border-top: 5px solid $dark-500;
}

View File

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

View File

@@ -0,0 +1,100 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import { UpgradeButton } from '../../../generic/upgrade-button';
import VerifiedCert from '../../../generic/assets/edX_certificate.png';
function UpgradeCard({ courseId, intl, onLearnMore }) {
const { org } = useModel('courseHomeMeta', courseId);
const {
offer,
verifiedMode,
} = useModel('outline', courseId);
if (!verifiedMode) {
return null;
}
const eventProperties = {
org_key: org,
courserun_key: courseId,
};
const promotionEventProperties = {
creative: 'sidebarupsell',
name: 'In-Course Verification Prompt',
position: 'sidebar-message',
promotion_id: 'courseware_verified_certificate_upsell',
...eventProperties,
};
useEffect(() => {
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.displayed', eventProperties);
sendTrackEvent('Promotion Viewed', promotionEventProperties);
});
const logClick = () => {
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.clicked', eventProperties);
sendTrackingLogEvent('edx.course.enrollment.upgrade.clicked', {
...eventProperties,
location: 'sidebar-message',
});
sendTrackEvent('Promotion Clicked', promotionEventProperties);
};
return (
<section className="mb-4 p-3 outline-sidebar-upgrade-card">
<h2 className="h4" id="outline-sidebar-upgrade-header">{intl.formatMessage(messages.upgradeTitle)}</h2>
<div className="row w-100 m-0">
<div className="col-6 col-md-12 col-lg-3 col-xl-4 p-0 text-md-center text-lg-left">
<img
alt={intl.formatMessage(messages.certAlt)}
className="w-100"
src={VerifiedCert}
style={{ maxWidth: '10rem' }}
/>
</div>
<div className="col-6 col-md-12 col-lg-9 col-xl-8 p-0 pl-lg-2 text-center mt-md-2 mt-lg-0">
<div className="row w-100 m-0 justify-content-center">
<UpgradeButton
offer={offer}
onClick={logClick}
verifiedMode={verifiedMode}
/>
{onLearnMore && (
<div className="col-12">
<Button
variant="link"
size="sm"
className="pb-0"
onClick={onLearnMore}
aria-labelledby="outline-sidebar-upgrade-header"
>
{intl.formatMessage(messages.learnMore)}
</Button>
</div>
)}
</div>
</div>
</div>
</section>
);
}
UpgradeCard.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
onLearnMore: PropTypes.func,
};
UpgradeCard.defaultProps = {
onLearnMore: null,
};
export default injectIntl(UpgradeCard);

View File

@@ -0,0 +1,4 @@
.outline-sidebar-upgrade-card {
border: 1px solid $dark-500;
border-top: 5px solid $dark-500;
}

View File

@@ -2,6 +2,8 @@ import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, TransitionReplace } from '@edx/paragon';
import truncate from 'truncate-html';
import { useDispatch } from 'react-redux';
import LmsHtmlFragment from '../LmsHtmlFragment';
@@ -21,8 +23,9 @@ function WelcomeMessage({ courseId, intl }) {
const [display, setDisplay] = useState(true);
const shortWelcomeMessageHtml = welcomeMessageHtml.length > 200 && `${welcomeMessageHtml.substring(0, 199)}...`;
const [showShortMessage, setShowShortMessage] = useState(!!shortWelcomeMessageHtml);
const shortWelcomeMessageHtml = truncate(welcomeMessageHtml, 100, { byWords: true, keepWhitespaces: true });
const messageCanBeShortened = shortWelcomeMessageHtml.length < welcomeMessageHtml.length;
const [showShortMessage, setShowShortMessage] = useState(messageCanBeShortened);
const dispatch = useDispatch();
return (
@@ -34,27 +37,40 @@ function WelcomeMessage({ courseId, intl }) {
setDisplay(false);
dispatch(dismissWelcomeMessage(courseId));
}}
>
<div className="my-3">
<LmsHtmlFragment
html={showShortMessage ? shortWelcomeMessageHtml : welcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
</div>
{
shortWelcomeMessageHtml && (
<div className="d-flex justify-content-end">
<button
type="button"
className="btn rounded align-self-center border border-primary bg-white font-weight-bold mb-3"
footer={messageCanBeShortened && (
<div className="row w-100 m-0">
<div className="col-12 col-sm-auto p-0">
<Button
block
onClick={() => setShowShortMessage(!showShortMessage)}
variant="outline-primary"
>
{showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton)
: intl.formatMessage(messages.welcomeMessageShowLessButton)}
</button>
</Button>
</div>
)
}
</div>
)}
>
<TransitionReplace className="mb-3" enterDuration={200} exitDuration={200}>
{showShortMessage ? (
<LmsHtmlFragment
className="inline-link"
data-testid="short-welcome-message-iframe"
key="short-html"
html={shortWelcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
) : (
<LmsHtmlFragment
className="inline-link"
data-testid="long-welcome-message-iframe"
key="full-html"
html={welcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
)}
</TransitionReplace>
</Alert>
)
);

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { requestCert } from '../data/thunks';
import { useModel } from '../../generic/model-store';
import messages from './messages';
import VerifiedCert from '../../generic/assets/edX_certificate.png';
function CertificateBanner({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
certificateData,
enrollmentMode,
} = useModel('progress', courseId);
if (certificateData === null || enrollmentMode === 'audit') { return null; }
const { certUrl, certDownloadUrl } = certificateData;
const dispatch = useDispatch();
function requestHandler() {
dispatch(requestCert(courseId));
}
return (
<section className="banner rounded my-4 p-4 container-fluid border border-primary-200 bg-info-100 row">
<div className="col-12 col-sm-9">
<div>
<div className="font-weight-bold">{certificateData.title}</div>
<div className="mt-1">{certificateData.msg}</div>
</div>
{certUrl && (
<div>
<a className="btn btn-primary my-3" href={certUrl} rel="noopener noreferrer" target="_blank">
{intl.formatMessage(messages.viewCert)}
<span className="sr-only">{intl.formatMessage(messages.opensNewWindow)}</span>
</a>
</div>
)}
{!certUrl && certificateData.isDownloadable && (
<div>
<a className="btn btn-primary my-3" href={certDownloadUrl} rel="noopener noreferrer" target="_blank">
{intl.formatMessage(messages.downloadCert)}
<span className="sr-only">{intl.formatMessage(messages.opensNewWindow)}</span>
</a>
</div>
)}
{!certUrl && !certificateData.isDownloadable && certificateData.isRequestable && (
<div className="my-3">
<button className="btn btn-primary" type="button" onClick={requestHandler}>
{intl.formatMessage(messages.requestCert)}
</button>
</div>
)}
</div>
<div className="col-0 col-sm-3 d-none d-sm-block">
<img
alt={intl.formatMessage(messages.certAlt)}
src={VerifiedCert}
className="float-right"
style={{ height: '120px' }}
/>
</div>
</section>
);
}
CertificateBanner.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CertificateBanner);

View File

@@ -0,0 +1,151 @@
import React from 'react';
import { useSelector } from 'react-redux';
import {
FormattedDate, FormattedTime, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { useModel } from '../../generic/model-store';
import messages from './messages';
function CreditRequirements({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
creditCourseRequirements,
creditSupportUrl,
verificationData,
userTimezone,
} = useModel('progress', courseId);
if (creditCourseRequirements === null) { return null; }
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const eligibility = creditCourseRequirements.eligibilityStatus;
let message;
switch (eligibility) {
case 'not_eligible':
message = intl.formatMessage(messages.creditNotEligible);
break;
case 'eligible':
message = intl.formatMessage(messages.creditEligible);
break;
case 'partial_eligible':
message = intl.formatMessage(messages.creditPartialEligible);
break;
default:
break;
}
const completed = `${intl.formatMessage(messages.completed)} `;
const { status } = verificationData;
let verificationMessage;
let verificationLinkMessage = '';
switch (status) {
case 'none':
case 'expired':
verificationMessage = `${intl.formatMessage(messages.notStarted)}; `;
verificationLinkMessage = intl.formatMessage(messages.notStarted);
break;
case 'approved':
verificationMessage = completed;
break;
case 'pending':
verificationMessage = intl.formatMessage(messages.pending);
break;
case 'must_reverify':
verificationMessage = `${intl.formatMessage(messages.rejected)}; `;
verificationLinkMessage = intl.formatMessage(messages.tryAgain);
break;
default:
break;
}
return (
<section className="banner rounded row border border-primary-300 my-2">
<div className="col ml-4 my-3">
<div className="row font-weight-bold">
{intl.formatMessage(messages.courseCreditHeader)}
</div>
<div className="row mb-2">{message}</div>
{creditCourseRequirements.requirements.map((requirement) => (
<div key={requirement.displayName} className="row w-50 border-bottom">
<div className="col-4">
{requirement.displayName}
{requirement.minGrade && (
<span>{` ${requirement.minGrade}%`}</span>
)}
</div>
<div className="col-8">
{!requirement.status && (
intl.formatMessage(messages.notMet)
)}
{(requirement.status === 'failed' || requirement.status === 'declined') && (
intl.formatMessage(messages.failed)
)}
{requirement.status === 'submitted' && (
intl.formatMessage(messages.submitted)
)}
{requirement.status === 'satisfied' && (
<span>
{completed}
{requirement.statusDate && (
<span>
<FormattedDate
value={requirement.statusDate}
day="numeric"
month="short"
weekday="short"
year="numeric"
{...timezoneFormatArgs}
/> <FormattedTime
value={requirement.statusDate}
/>
</span>
)}
</span>
)}
</div>
</div>
))}
<div className="row w-50 border-bottom">
<div className="col-4">Verification Status </div>
<div className="col-8">
{verificationMessage}
{verificationLinkMessage && (
<a href={verificationData.link}>{verificationLinkMessage}</a>
)}
{status === 'approved' && verificationData.statusDate && (
<span>
<FormattedDate
value={verificationData.statusDate}
day="numeric"
month="short"
weekday="short"
year="numeric"
{...timezoneFormatArgs}
/>
</span>
)}
</div>
</div>
{eligibility === 'eligible' && (
<div className="mt-3 row">
<a className="btn btn-primary" href={creditCourseRequirements.dashboardUrl}>{intl.formatMessage(messages.purchaseCredit)}</a>
</div>
)}
<div className="mt-3 row">
<a href={creditSupportUrl}>{intl.formatMessage(messages.learnMoreCredit)}</a>
</div>
</div>
</section>
);
}
CreditRequirements.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CreditRequirements);

View File

@@ -1,24 +1,36 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useModel } from '../../generic/model-store';
import Chapter from './Chapter';
import CertificateBanner from './CertificateBanner';
import messages from './messages';
import CreditRequirements from './CreditRequirements';
export default function ProgressTab() {
function ProgressTab({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const { administrator, username } = getAuthenticatedUser();
const { administrator } = getAuthenticatedUser();
const {
enrollmentMode,
coursewareSummary,
studioUrl,
} = useModel('progress', courseId);
return (
<section>
{enrollmentMode} {administrator} {username}
{administrator && studioUrl && (
<div className="row mb-3 mr-3 justify-content-end">
<a className="btn-sm border border-info" href={studioUrl}>
{intl.formatMessage(messages.studioLink)}
</a>
</div>
)}
<CertificateBanner />
<CreditRequirements />
{coursewareSummary.map((chapter) => (
<Chapter
key={chapter.displayName}
@@ -28,3 +40,9 @@ export default function ProgressTab() {
</section>
);
}
ProgressTab.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(ProgressTab);

View File

@@ -24,6 +24,7 @@ function Subsection({
<section className="my-3 ml-3">
<div className="row">
<a className="h6" href={subsection.url}>
{/* eslint-disable-next-line react/no-danger */}
<div dangerouslySetInnerHTML={{ __html: subsection.displayName }} />
{showTotalScore && <span className="sr-only">{totalScoreSr}</span>}
</a>

View File

@@ -33,6 +33,91 @@ const messages = defineMessages({
id: 'learning.progress.badge.scoreEarned',
defaultMessage: '{earned} of {total} possible points',
},
viewCert: {
id: 'learning.progress.badge.viewCert',
defaultMessage: 'View Certificate',
},
downloadCert: {
id: 'learning.progress.badge.downloadCert',
defaultMessage: 'Download Your Certificate',
},
requestCert: {
id: 'learning.progress.badge.requestCert',
defaultMessage: 'Request Certificate',
},
opensNewWindow: {
id: 'learning.progress.badge.opensNewWindow',
defaultMessage: 'Opens in a new browser window',
},
certAlt: {
id: 'learning.progress.badge.certAlt',
defaultMessage: 'Example Certificate',
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
},
studioLink: {
id: 'learning.progress.badge.studioLink',
defaultMessage: 'View grading in Studio',
},
courseCreditHeader: {
id: 'learning.progress.courseCreditHeader',
defaultMessage: 'Course Credit Eligibility',
},
creditNotEligible: {
id: 'learning.progress.creditNotEligible',
defaultMessage: 'You are not eligible for course credit because you have not met the requirements for credit.',
},
creditEligible: {
id: 'learning.progress.creditEligible',
defaultMessage: 'You have met the requirements for credit in this course.',
},
creditPartialEligible: {
id: 'learning.progress.creditPartialEligible',
defaultMessage: 'You have not met the minimum requirements for credit.',
},
start: {
id: 'learning.progress.startVerification',
defaultMessage: 'Start now',
},
tryAgain: {
id: 'learning.progress.start',
defaultMessage: 'Try again',
},
notStarted: {
id: 'learning.progress.notStarted',
defaultMessage: 'Not started',
},
failed: {
id: 'learning.progress.failed',
defaultMessage: 'Incomplete',
},
notMet: {
id: 'learning.progress.notMet',
defaultMessage: 'Not met',
},
pending: {
id: 'learning.progress.pending',
defaultMessage: 'Pending',
},
rejected: {
id: 'learning.progress.rejected',
defaultMessage: 'Rejected',
},
completed: {
id: 'learning.progress.completed',
defaultMessage: 'Completed',
},
submitted: {
id: 'learning.progress.submitted',
defaultMessage: 'Submitted',
},
learnMoreCredit: {
id: 'learning.progress.learnMoreCredit',
defaultMessage: 'Learn more about course credit',
},
purchaseCredit: {
id: 'learning.progress.purchaseCredit',
defaultMessage: 'Purchase course credit',
},
});
export default messages;

View File

@@ -125,9 +125,15 @@ class CoursewareContainer extends Component {
handleUnitNavigationClick = (nextUnitId) => {
const {
courseId, sequenceId, unitId,
courseId, sequenceId,
match: {
params: {
unitId: routeUnitId,
},
},
} = this.props;
this.props.checkBlockCompletion(courseId, sequenceId, unitId);
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
}
@@ -171,7 +177,15 @@ class CoursewareContainer extends Component {
}
renderDenied() {
const { courseId, course } = this.props;
const {
course,
courseId,
match: {
params: {
unitId: routeUnitId,
},
},
} = this.props;
let url = `/redirect/course-home/${courseId}`;
switch (course.canLoadCourseware.errorCode) {
case 'audit_expired':
@@ -186,6 +200,9 @@ class CoursewareContainer extends Component {
case 'unfulfilled_milestones':
url = '/redirect/dashboard';
break;
case 'microfrontend_disabled':
url = `/redirect/courseware/${courseId}/unit/${routeUnitId}`;
break;
case 'authentication_required':
case 'enrollment_required':
default:
@@ -217,6 +234,7 @@ class CoursewareContainer extends Component {
courseId={courseId}
unitId={routeUnitId}
courseStatus={courseStatus}
metadataModel="coursewareMeta"
>
<Course
courseId={courseId}
@@ -256,7 +274,6 @@ CoursewareContainer.propTypes = {
courseId: PropTypes.string,
sequenceId: PropTypes.string,
firstSequenceId: PropTypes.string,
unitId: PropTypes.string,
courseStatus: PropTypes.oneOf(['loaded', 'loading', 'failed', 'denied']).isRequired,
sequenceStatus: PropTypes.oneOf(['loaded', 'loading', 'failed']).isRequired,
nextSequence: sequenceShape,
@@ -273,7 +290,6 @@ CoursewareContainer.defaultProps = {
courseId: null,
sequenceId: null,
firstSequenceId: null,
unitId: null,
nextSequence: null,
previousSequence: null,
course: null,
@@ -281,7 +297,7 @@ CoursewareContainer.defaultProps = {
};
const currentCourseSelector = createSelector(
(state) => state.models.courses || {},
(state) => state.models.coursewareMeta || {},
(state) => state.courseware.courseId,
(coursesById, courseId) => (coursesById[courseId] ? coursesById[courseId] : null),
);
@@ -353,13 +369,12 @@ const firstSequenceIdSelector = createSelector(
const mapStateToProps = (state) => {
const {
courseId, sequenceId, unitId, courseStatus, sequenceStatus,
courseId, sequenceId, courseStatus, sequenceStatus,
} = state.courseware;
return {
courseId,
sequenceId,
unitId,
courseStatus,
sequenceStatus,
course: currentCourseSelector(state),

View File

@@ -1,7 +1,7 @@
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { waitForElementToBeRemoved } from '@testing-library/dom';
import { waitForElementToBeRemoved, fireEvent } from '@testing-library/dom';
import '@testing-library/jest-dom/extend-expect';
import { render, screen } from '@testing-library/react';
import React from 'react';
@@ -11,7 +11,7 @@ import MockAdapter from 'axios-mock-adapter';
import { UserMessagesProvider } from '../generic/user-messages';
import tabMessages from '../tab-page/messages';
import initializeMockApp from '../setupTest';
import { initializeMockApp } from '../setupTest';
import CoursewareContainer from './CoursewareContainer';
import buildSimpleCourseBlocks from './data/__factories__/courseBlocks.factory';
@@ -33,6 +33,8 @@ jest.mock(
() => MockUnit,
);
jest.mock('@edx/frontend-platform/analytics');
initializeMockApp();
describe('CoursewareContainer', () => {
@@ -261,6 +263,26 @@ describe('CoursewareContainer', () => {
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
});
it('should navigate between units and check block completion', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
const { container } = render(component);
axiosMock.onPost(`${courseId}/xblock/${sequenceBlock.id}/handler/xmodule_handler/get_completion`).reply(200, {
complete: true,
});
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
const sequenceNextButton = sequenceNavButtons[4];
expect(sequenceNextButton).toHaveTextContent('Next');
fireEvent.click(sequenceNavButtons[4]);
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${sequenceBlock.id}/${unitBlocks[1].id}`);
});
});
describe('when the current sequence is an exam', () => {

View File

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

View File

@@ -1,8 +1,11 @@
import React from 'react';
import { Switch, Route, useRouteMatch } from 'react-router';
import { Switch, useRouteMatch } from 'react-router';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import PageLoading from './generic/PageLoading';
import { PageRoute } from '@edx/frontend-platform/react';
import PageLoading from '../generic/PageLoading';
import CoursewareRedirect from './CoursewareRedirect';
export default () => {
const { path } = useRouteMatch();
@@ -18,13 +21,17 @@ export default () => {
/>
<Switch>
<Route
<PageRoute
path={`${path}/courseware/:courseId/unit/:unitId`}
component={CoursewareRedirect}
/>
<PageRoute
path={`${path}/course-home/:courseId`}
render={({ match }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/course/`);
}}
/>
<Route
<PageRoute
path={`${path}/dashboard`}
render={({ location }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`);

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { useDispatch } from 'react-redux';
@@ -11,9 +11,9 @@ import useOfferAlert from '../../alerts/offer-alert';
import Sequence from './sequence';
import { CelebrationModal, shouldCelebrateOnSectionLoad } from './celebration';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import CourseSock from './course-sock';
import ContentTools from './content-tools';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import CourseSock from '../../generic/course-sock';
import { useModel } from '../../generic/model-store';
function Course({
@@ -24,7 +24,7 @@ function Course({
previousSequenceHandler,
unitNavigationHandler,
}) {
const course = useModel('courses', courseId);
const course = useModel('coursewareMeta', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sequence ? sequence.sectionId : null);
@@ -35,21 +35,52 @@ function Course({
].filter(element => element != null).map(element => element.title);
const {
accessExpiration,
canShowUpgradeSock,
celebrations,
courseExpiredMessage,
offerHtml,
offer,
org,
userTimezone,
verifiedMode,
} = course;
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
const offerAlert = useOfferAlert(offerHtml, 'course');
const accessExpirationAlert = useAccessExpirationAlert(courseExpiredMessage, 'course');
const offerAlert = useOfferAlert(offer, userTimezone, 'course');
const accessExpirationAlert = useAccessExpirationAlert(accessExpiration, userTimezone, 'course');
const dispatch = useDispatch();
const celebrateFirstSection = celebrations && celebrations.firstSection;
const celebrationOpen = shouldCelebrateOnSectionLoad(courseId, sequenceId, unitId, celebrateFirstSection, dispatch);
// The below block of code should be reverted after the REV1512 experiment
const [REV1512FlyoverEnabled, setREV1512FlyoverEnabled] = useState(false);
window.enableREV1512Flyover = () => {
setREV1512FlyoverEnabled(true);
};
const getCookie = (name) => {
const match = document.cookie.match(`${name}=([^;]*)`);
return match ? match[1] : undefined;
};
const userAgent = typeof window.navigator === 'undefined' ? '' : navigator.userAgent;
const isMobile = Boolean(
userAgent.match(/Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i),
);
const [REV1512FlyoverVisible, setREV1512FlyoverVisible] = useState(isMobile ? false : !(getCookie(`REV1512FlyoverVisible${courseId}`) === 'false'));
const isREV1512FlyoverVisible = () => REV1512FlyoverEnabled && (REV1512FlyoverVisible || getCookie('REV1512FlyoverVisible') === 'true');
const toggleREV1512Flyover = () => {
const setCookie = (cookieName, value, domain) => {
const cookieDomain = (typeof domain === 'undefined') ? '' : `domain=${domain};`;
const exdate = new Date();
exdate.setHours(exdate.getHours() + 4);
const cookieValue = `${escape(value)}; expires=${exdate.toUTCString()}`;
document.cookie = `${cookieName}=${cookieValue};${cookieDomain}path=/`;
};
const isVisible = isREV1512FlyoverVisible();
setCookie(`REV1512FlyoverVisible${courseId}`, !isVisible);
setREV1512FlyoverVisible(!isVisible);
};
// The above block of code should be reverted after the REV1512 experiment
return (
<>
<Helmet>
@@ -67,6 +98,9 @@ function Course({
courseId={courseId}
sectionId={section ? section.id : null}
sequenceId={sequenceId}
toggleREV1512Flyover={toggleREV1512Flyover} /* This line should be reverted after REV1512 experiment */
REV1512FlyoverEnabled={REV1512FlyoverEnabled} /* This line should be reverted after REV1512 experiment */
isREV1512FlyoverVisible={isREV1512FlyoverVisible} /* This line should be reverted after REV1512 experiment */
/>
<AlertList topic="sequence" />
<Sequence
@@ -76,6 +110,9 @@ function Course({
unitNavigationHandler={unitNavigationHandler}
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
toggleREV1512Flyover={toggleREV1512Flyover} /* This line should be reverted after REV1512 experiment */
isREV1512FlyoverVisible={isREV1512FlyoverVisible} /* This line should be reverted after REV1512 experiment */
REV1512FlyoverEnabled={REV1512FlyoverEnabled} /* This line should be reverted after REV1512 experiment */
/>
{celebrationOpen && (
<CelebrationModal
@@ -83,7 +120,15 @@ function Course({
open
/>
)}
{canShowUpgradeSock && verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
{canShowUpgradeSock && (
<CourseSock
courseId={courseId}
offer={offer}
orgKey={org}
pageLocation="Course Content Page"
verifiedMode={verifiedMode}
/>
)}
<ContentTools course={course} />
</>
);

View File

@@ -0,0 +1,146 @@
import React from 'react';
import { Factory } from 'rosie';
import {
loadUnit, render, screen, waitFor, getByRole, initializeTestStore, fireEvent,
} from '../../setupTest';
import Course from './Course';
import { handleNextSectionCelebration } from './celebration';
import * as celebrationUtils from './celebration/utils';
jest.mock('@edx/frontend-platform/analytics');
const recordFirstSectionCelebration = jest.fn();
celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
describe('Course', () => {
let store;
const mockData = {
nextSequenceHandler: () => {},
previousSequenceHandler: () => {},
unitNavigationHandler: () => {},
};
beforeAll(async () => {
store = await initializeTestStore();
const { courseware, models } = store.getState();
const { courseId, sequenceId } = courseware;
Object.assign(mockData, {
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
});
});
it('loads learning sequence', async () => {
render(<Course {...mockData} />);
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Learn About Verified Certificates' })).not.toBeInTheDocument();
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
const { models } = store.getState();
const sequence = models.sequences[mockData.sequenceId];
const section = models.sections[sequence.sectionId];
const course = models.coursewareMeta[mockData.courseId];
expect(document.title).toMatch(
`${sequence.title} | ${section.title} | ${course.title} | edX`,
);
});
it('displays celebration modal', async () => {
// TODO: Remove these console mocks after merging https://github.com/edx/paragon/pull/526.
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
const courseMetadata = Factory.build('courseMetadata', { celebrations: { firstSection: true } });
const testStore = await initializeTestStore({ courseMetadata }, false);
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
};
// Set up LocalStorage for testing.
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
render(<Course {...testData} />, { store: testStore });
const celebrationModal = screen.getByRole('dialog');
expect(celebrationModal).toBeInTheDocument();
expect(getByRole(celebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
});
it('displays upgrade sock', async () => {
const courseMetadata = Factory.build('courseMetadata', { can_show_upgrade_sock: true });
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
expect(screen.getByRole('button', { name: 'Learn About Verified Certificates' })).toBeInTheDocument();
});
it('displays offer and expiration alert', async () => {
const courseMetadata = Factory.build('courseMetadata', {
access_expiration: {
expiration_date: '2080-01-01T12:00:00Z',
masquerading_expired_course: false,
upgrade_deadline: null,
upgrade_url: null,
},
offer: {
code: 'EDXWELCOME',
expiration_date: '2070-01-01T12:00:00Z',
original_price: '$100',
discounted_price: '$85',
percentage: 15,
upgrade_url: 'https://example.com/upgrade',
},
});
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
await screen.findByText('EDXWELCOME');
await screen.findByText('Audit Access Expires');
});
it('passes handlers to the sequence', async () => {
const nextSequenceHandler = jest.fn();
const previousSequenceHandler = jest.fn();
const unitNavigationHandler = jest.fn();
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.
nextSequenceHandler,
previousSequenceHandler,
unitNavigationHandler,
};
render(<Course {...testData} />, { store: testStore });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
// We are in the middle of the sequence, so no
expect(previousSequenceHandler).not.toHaveBeenCalled();
expect(nextSequenceHandler).not.toHaveBeenCalled();
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
});
});

View File

@@ -13,10 +13,10 @@ function CourseBreadcrumb({
return (
<>
{withSeparator && (
<li className="mx-2 text-gray-300" role="presentation" aria-hidden>/</li>
<li className="mx-2 text-primary-500" role="presentation" aria-hidden>/</li>
)}
<li {...attrs}>
<a href={url}>{children}</a>
<a className="text-primary-500" href={url}>{children}</a>
</li>
</>
);
@@ -36,8 +36,11 @@ export default function CourseBreadcrumbs({
courseId,
sectionId,
sequenceId,
toggleREV1512Flyover, /* This line should be reverted after the REV1512 experiment */
REV1512FlyoverEnabled, /* This line should be reverted after the REV1512 experiment */
isREV1512FlyoverVisible, /* This line should be reverted after the REV1512 experiment */
}) {
const course = useModel('courses', courseId);
const course = useModel('coursewareMeta', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sectionId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
@@ -54,6 +57,12 @@ export default function CourseBreadcrumbs({
return [];
}, [courseStatus, sequenceStatus]);
// These should be reverted after the REV1512 experiment
const userAgent = typeof window.navigator === 'undefined' ? '' : navigator.userAgent;
const isMobile = Boolean(
userAgent.match(/Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i),
);
return (
<nav aria-label="breadcrumb" className="my-4">
<ol className="list-unstyled d-flex m-0">
@@ -82,6 +91,22 @@ export default function CourseBreadcrumbs({
{label}
</CourseBreadcrumb>
))}
{/* The below block of code should be reverted after the REV1512 experiment */}
{REV1512FlyoverEnabled
&& !isMobile && (
<div
className="toggleFlyoverButton"
aria-hidden="true"
style={{ marginLeft: 'auto', marginTop: '-16px', borderBottom: isREV1512FlyoverVisible() ? '2px solid #00262b' : 'none' }}
onClick={() => {
toggleREV1512Flyover();
}}
>
<svg width="54" height="40" viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="53" height="39" rx="1.5" fill="white" stroke="#E7E8E9" /><path d="M36 20C36 15.6 32.4 12 28 12C27.7 12 27.5 12.2 27.5 12.5C27.5 12.8 27.7 13 28 13C31.85 13 35 16.15 35 20C35 23.85 31.85 27 28 27C24.15 27 21 23.85 21 20C21 19.7 20.8 19.5 20.5 19.5C20.3 19.5 20.1 19.65 20.05 19.8C20 19.85 20 19.95 20 20C20 24.4 23.6 28 28 28C32.4 28 36 24.4 36 20Z" fill="black" stroke="black" strokeWidth="0.6" /><path d="M23.1065 14.52C22.9403 14.36 22.691 14.36 22.5247 14.52C22.3585 14.68 22.3585 14.92 22.5247 15.08C22.6078 15.16 22.7325 15.2 22.8156 15.2C22.9403 15.2 23.0234 15.16 23.1065 15.08C23.2312 14.96 23.2312 14.68 23.1065 14.52Z" fill="black" stroke="black" strokeWidth="0.6" /><path d="M27.6848 15.2C27.3939 15.2 27.2 15.3973 27.2 15.6932V19.6384C27.2 19.6877 27.2 19.7863 27.2484 19.8356C27.2969 19.8849 27.2969 19.9343 27.3454 19.9836L29.5757 22.2521C29.6727 22.3507 29.8181 22.4 29.9151 22.4C30.0121 22.4 30.1575 22.3507 30.2545 22.2521C30.4484 22.0548 30.4484 21.7589 30.2545 21.5617L28.1696 19.4411V15.6932C28.1696 15.3973 27.9757 15.2 27.6848 15.2Z" fill="black" stroke="black" strokeWidth="0.6" /><circle cx="35.5" cy="14.5" r="4.5" fill="#C32D3A" />
</svg>
</div>
)}
</ol>
</nav>
);
@@ -91,6 +116,9 @@ CourseBreadcrumbs.propTypes = {
courseId: PropTypes.string.isRequired,
sectionId: PropTypes.string,
sequenceId: PropTypes.string,
toggleREV1512Flyover: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
REV1512FlyoverEnabled: PropTypes.bool.isRequired, /* This line should be reverted after the REV1512 experiment */
isREV1512FlyoverVisible: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
};
CourseBreadcrumbs.defaultProps = {

View File

@@ -40,7 +40,8 @@ export default function BookmarkButton({
return (
<StatefulButton
className="btn-link px-1 ml-n1 btn-sm"
variant="link"
className="px-1 ml-n1 btn-sm text-primary-500"
onClick={toggleBookmark}
state={state}
disabledStates={['defaultProcessing', 'bookmarkedProcessing']}

View File

@@ -0,0 +1,94 @@
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import { Factory } from 'rosie';
import {
render, screen, fireEvent, initializeTestStore, waitFor, authenticatedUser, logUnhandledRequests,
} from '../../../setupTest';
import { BookmarkButton } from './index';
describe('Bookmark Button', () => {
let axiosMock;
let store;
const courseMetadata = Factory.build('courseMetadata');
const mockData = {
isProcessing: false,
};
const nonBookmarkedUnitBlock = Factory.build(
'block',
{ type: 'vertical' },
{ courseId: courseMetadata.id },
);
const bookmarkedUnitBlock = Factory.build(
'block',
{ type: 'vertical', bookmarked: true },
{ courseId: courseMetadata.id },
);
const unitBlocks = [nonBookmarkedUnitBlock, bookmarkedUnitBlock];
beforeEach(async () => {
store = await initializeTestStore({ courseMetadata, unitBlocks });
mockData.unitId = nonBookmarkedUnitBlock.id;
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const bookmarkUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
axiosMock.onPost(bookmarkUrl).reply(200, { });
const bookmarkDeleteUrlRegExp = new RegExp(`${bookmarkUrl}*,*`);
axiosMock.onDelete(bookmarkDeleteUrlRegExp).reply(200, { });
logUnhandledRequests(axiosMock);
});
it('handles adding bookmark', async () => {
render(<BookmarkButton {...mockData} />);
const button = screen.getByRole('button', { name: 'Bookmark this page' });
expect(button).not.toHaveClass('disabled');
fireEvent.click(button);
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
expect(axiosMock.history.post[0].data).toEqual(JSON.stringify({ usage_id: nonBookmarkedUnitBlock.id }));
expect(store.getState().models.units[nonBookmarkedUnitBlock.id].bookmarked).toBeTruthy();
});
it('does not handle adding bookmark when processing', async () => {
render(<BookmarkButton {...mockData} isProcessing />);
const button = screen.getByRole('button', { name: 'Bookmark this page' });
expect(button).toHaveClass('disabled');
fireEvent.click(button);
// HACK: We don't have a function we could reliably await here, so this test relies on the timeout of `waitFor`.
await expect(waitFor(
() => expect(axiosMock.history.post).toHaveLength(1),
{ timeout: 100 },
)).rejects.toThrowError(/expect.*toHaveLength.*/);
expect(store.getState().models.units[nonBookmarkedUnitBlock.id].bookmarked).toBeFalsy();
});
it('handles removing bookmark', async () => {
render(<BookmarkButton {...mockData} unitId={bookmarkedUnitBlock.id} isBookmarked />);
const button = screen.getByRole('button', { name: 'Bookmarked' });
fireEvent.click(button);
await waitFor(() => expect(axiosMock.history.delete).toHaveLength(1));
expect(axiosMock.history.delete[0].url).toContain(`${authenticatedUser.username},${bookmarkedUnitBlock.id}`);
expect(store.getState().models.units[bookmarkedUnitBlock.id].bookmarked).toBeFalsy();
});
it('does not handle removing bookmark when processing', async () => {
render(<BookmarkButton {...mockData} unitId={bookmarkedUnitBlock.id} isBookmarked isProcessing />);
const button = screen.getByRole('button', { name: 'Bookmarked' });
expect(button).toHaveClass('disabled');
fireEvent.click(button);
// HACK: We don't have a function we could reliably await here, so this test relies on the timeout of `waitFor`.
await expect(waitFor(
() => expect(axiosMock.history.delete).toHaveLength(1),
{ timeout: 100 },
)).rejects.toThrowError(/expect.*toHaveLength.*/);
expect(store.getState().models.units[bookmarkedUnitBlock.id].bookmarked).toBeTruthy();
});
});

View File

@@ -7,7 +7,7 @@ import * as thunks from './thunks';
import executeThunk from '../../../../utils';
import initializeMockApp from '../../../../setupTest';
import { initializeMockApp } from '../../../../setupTest';
import initializeStore from '../../../../store';
const { loggingService } = initializeMockApp();

View File

@@ -7,12 +7,15 @@ import { layoutGenerator } from 'react-break';
import ClapsMobile from './assets/claps_280x201.gif';
import ClapsTablet from './assets/claps_456x328.gif';
import messages from './messages';
import SocialIcons from './SocialIcons';
import SocialIcons from '../../social-share/SocialIcons';
import { recordFirstSectionCelebration } from './utils';
import { useModel } from '../../../generic/model-store';
function CelebrationModal({
courseId, intl, open, ...rest
}) {
const { org } = useModel('coursewareMeta', courseId);
const layout = layoutGenerator({
mobile: 0,
tablet: 400,
@@ -23,7 +26,7 @@ function CelebrationModal({
useEffect(() => {
if (open) {
recordFirstSectionCelebration(courseId);
recordFirstSectionCelebration(org, courseId);
}
}, [open]);
@@ -41,7 +44,12 @@ function CelebrationModal({
<p className="mt-3">
<strong>{intl.formatMessage(messages.earned)}</strong> {intl.formatMessage(messages.share)}
</p>
<SocialIcons courseId={courseId} />
<SocialIcons
analyticsId="edx.ui.lms.celebration.social_share.clicked"
courseId={courseId}
emailSubject={messages.emailSubject}
socialMessage={messages.socialMessage}
/>
</>
)}
closeText={intl.formatMessage(messages.forward)}

View File

@@ -13,14 +13,9 @@ const messages = defineMessages({
id: 'learning.celebration.earned',
defaultMessage: 'You earned it!',
},
emailBody: {
id: 'learning.celebration.emailBody',
defaultMessage: 'What are you spending your time learning?',
description: 'Body when sharing course progress via email',
},
emailSubject: {
id: 'learning.celebration.emailSubject',
defaultMessage: "I'm on my way to completing {title} online with @edxonline!",
defaultMessage: "I'm on my way to completing {title} online with {platform}!",
description: 'Subject when sharing course progress via email',
},
forward: {
@@ -32,15 +27,7 @@ const messages = defineMessages({
id: 'learning.celebration.share',
defaultMessage: 'Take a moment to celebrate and share your progress.',
},
shareEmail: {
id: 'learning.celebration.share.email',
defaultMessage: 'Share your progress via email.',
},
shareService: {
id: 'learning.celebration.share.service',
defaultMessage: 'Share your progress on {service}.',
},
social: {
socialMessage: {
id: 'learning.celebration.social',
defaultMessage: 'Im on my way to completing {title} online with {platform}. What are you spending your time learning?',
description: 'Shown when sharing course progress on a social network',

View File

@@ -16,14 +16,16 @@ function handleNextSectionCelebration(sequenceId, nextSequenceId, nextUnitId) {
});
}
function recordFirstSectionCelebration(courseId) {
function recordFirstSectionCelebration(org, courseId) {
// Tell the LMS
postFirstSectionCelebrationComplete(courseId);
// Tell our analytics
const { administrator } = getAuthenticatedUser();
sendTrackEvent('edx.ui.lms.celebration.first_section.opened', {
course_id: courseId,
org_key: org,
courserun_key: courseId,
course_id: courseId, // should only be courserun_key, but left as-is for historical reasons
is_staff: administrator,
});
}
@@ -50,7 +52,7 @@ function shouldCelebrateOnSectionLoad(courseId, sequenceId, unitId, celebrateFir
// Update our local copy of course data from LMS
dispatch(updateModel({
modelType: 'courses',
modelType: 'coursewareMeta',
model: {
id: courseId,
celebrations: {

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