Compare commits

...

137 Commits

Author SHA1 Message Date
David Joy
d997431382 Adding constants and renaming src/course to src/active-course
src/course was confusing.  And we had no constants for the various ‘loading’ strings all over the place.
2020-08-07 10:24:23 -04:00
David Joy
aca3519a7d Improving some imports. 2020-08-06 17:24:41 -04:00
David Joy
1b38b02b9c Fixing merge conflicts in course-home tests. 2020-08-06 17:13:21 -04:00
David Joy
8967edee8d Moving courseStatus and courseId to “activeCourse” reducer.
Removing both from courseware and courseHome reducers.  This makes it easier for various portions of the app to find out what the current course is without being aware of which page is loaded.

In this MFE, we can basically always assume _some_ course is loaded.
2020-08-06 17:05:14 -04:00
David Joy
2c9fcce01e Moving CoursewareRedirect into courseware 2020-08-06 16:58:11 -04:00
Nick
9a315aa29d AA-264 mfe courseware reset dates (#165)
- add toast display after successful reset of dates via banner.
2020-08-06 15:20:53 -04:00
stvn
ad74b2295b Merge PR #106 add/masquerade-username
* Commits:
  Add "masquerade as specific student" support
2020-08-06 12:06:47 -07:00
stvn
c8961d3777 Add "masquerade as specific student" support 2020-08-06 09:07:53 -07:00
Dillon Dumesnil
c7c401e385 AA-275: Persist if the original user was staff for Instructor Toolbar (#167)
We want to be able to know if both the original user is a Staff user
as well as if the user being masqueraded as is staff. This updates
to accept both of these fields
2020-08-06 08:58:47 -07:00
Michael Terry
9e0f192ae7 AA-278 & AA-279: Add offer and course expired alerts to outline (#164)
* AA-278: Add offer alert to outline

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

* AA-279: Add course expired alert to outline

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

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

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

and:

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

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

* Hiding some warnings about console logging.

* Fixes bugs in CoursewareContainer

Fixes a few bugs in the courseware container:

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

Adds tests in CoursewareContainer for various URL and data states.

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

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

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

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

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

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

* Fixing test setup.  Improper use of sequenceMetadata factory!

Two problems:

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

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

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

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

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

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

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

* Adding a function to initialize the redux store

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

* Adding function to initialize a mock application.

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

* Using more explicit assertions for courseware reducer fields.

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

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

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

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

* Modifying sequenceMetadata factory to allow multiple units.

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

* A little linting and cleanup.

* Adding first round of tests for CoursewareContainer.

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

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

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

* Detect invalid sequence when loading

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

* Check sequenceId instead of sequence

From David Joy:

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

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

* Fix up some additional errors Piotr found

This fixes errors caused by deleting units or subsections.

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

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

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

* Add tests for fetchDatesTab, fetchOutlineTab, fetchSequence and resetDeadlines

* Implement fetch tabs tests

* Add fail test case for fetchSequence

* Add success test for fetchSequence

* Add test for resetDeadlines

* Update test group name

* Add empty tests for courseware and bookmarks

* Fix wrong field in saveSequencePosition thunk

* Add tests for courseware data layer

* Temporary commit

* Split tests after rebase

* Revert "Fix wrong field in saveSequencePosition thunk"

This reverts commit 4394d363c58ad929f81e587ce4da2241528494b5.

* Fix test for position

* Move executeThunk into utils

* Add test for all reducers

* Add expect statements for logs

* Remove redundant snapshot tests and add some specific checks

* Polishing

* Remove redundant checks

* Fix bug in normalizer and update test

* Upgrade @edx/frontend-platform dependency

* Utilize MockAuthService instead of manual auth package mocking

* Update tests after breaking changes in master

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

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

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

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

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

* Moving PageLoading into “generic” sub-directory.

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

* Moving InstructorToolbar and MasqueradeWidget up to instructor-toolbar.

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

* Co-locating celebration APIs with celebration utils.

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

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

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

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

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

* Add CourseLicense component with styling

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

* Revert "Add CourseLicense component with styling"

This reverts commit 8d154998de.

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

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

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

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

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

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

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

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

* Normalizing some module exports/naming.

* Moving alerts into a sub-directory.

* DRYing up alert hook creation into reusable useAlert hook.

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

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

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

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

* text overflow

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

Importing from the module, not its contents.

* Allowing customProps to be passed though AlertList to Alerts.

* UserMessagesProvider can create flash messages.

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

Once displayed, flash messages are cleared out of localStorage.

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

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

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

* moved around components

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

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

3
.env
View File

@@ -4,6 +4,7 @@ BASE_URL=null
CREDENTIALS_BASE_URL=null
CSRF_TOKEN_API_PATH=null
ECOMMERCE_BASE_URL=null
INSIGHTS_BASE_URL=
LANGUAGE_PREFERENCE_COOKIE_NAME=null
LMS_BASE_URL=null
LOGIN_URL=null
@@ -13,4 +14,6 @@ ORDER_HISTORY_URL=null
REFRESH_ACCESS_TOKEN_ENDPOINT=null
SEGMENT_KEY=null
SITE_NAME=null
TWITTER_URL=null
STUDIO_BASE_URL=
USER_INFO_COOKIE_NAME=null

View File

@@ -1,5 +1,4 @@
NODE_ENV='development'
PORT=2000
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:2000'
CREDENTIALS_BASE_URL='http://localhost:18150'
@@ -8,10 +7,13 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='localhost:1996/orders'
PORT=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'
TWITTER_URL='https://twitter.com/edXOnline'
STUDIO_BASE_URL='http://localhost:18010'
USER_INFO_COOKIE_NAME='edx-user-info'

View File

@@ -1,15 +1,19 @@
NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:1995'
BASE_URL='localhost:2000'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='localhost:1996/orders'
PORT=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'
TWITTER_URL='https://twitter.com/edXOnline'
STUDIO_BASE_URL='http://localhost:18010'
USER_INFO_COOKIE_NAME='edx-user-info'

View File

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

0
LICENSE Executable file → Normal file
View File

0
Makefile Executable file → Normal file
View File

View File

@@ -20,3 +20,26 @@ React app for edX learning.
:target: @edx/frontend-app-learning
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-learning.svg
:target: @edx/frontend-app-learning
Development
-----------
Start Devstack
^^^^^^^^^^^^^^
To use this application `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
- Start devstack
- Log in (http://localhost:18000/login)
Start the development server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
In this project, install requirements and start the development server by running:
.. code:: bash
npm install
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.

View File

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

View File

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

View File

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

View File

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

15866
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,34 +34,44 @@
"url": "https://github.com/edx/frontend-app-learning/issues"
},
"dependencies": {
"@edx/frontend-component-footer": "^10.0.6",
"@edx/frontend-component-header": "^2.0.3",
"@edx/frontend-platform": "^1.3.1",
"@edx/paragon": "^7.2.1",
"@fortawesome/fontawesome-svg-core": "^1.2.26",
"@fortawesome/free-brands-svg-icons": "^5.12.0",
"@fortawesome/free-regular-svg-icons": "^5.12.0",
"@fortawesome/free-solid-svg-icons": "^5.12.0",
"@fortawesome/react-fontawesome": "^0.1.8",
"@reduxjs/toolkit": "^1.2.3",
"classnames": "^2.2.6",
"core-js": "^3.6.2",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-redux": "^7.1.3",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"redux": "^4.0.5",
"regenerator-runtime": "^0.13.3"
"@edx/frontend-component-footer": "10.0.11",
"@edx/frontend-component-header": "2.0.5",
"@edx/frontend-platform": "1.5.2",
"@edx/paragon": "^9.1.1",
"@fortawesome/fontawesome-svg-core": "1.2.30",
"@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",
"@reduxjs/toolkit": "1.3.6",
"classnames": "2.2.6",
"core-js": "3.6.5",
"prop-types": "15.7.2",
"react": "16.13.1",
"react-break": "1.3.2",
"react-dom": "16.13.1",
"react-helmet": "6.0.0",
"react-redux": "7.2.1",
"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"
},
"devDependencies": {
"@edx/frontend-build": "^3.0.0",
"codecov": "^3.6.1",
"es-check": "^5.1.0",
"glob": "^7.1.6",
"husky": "^3.1.0",
"jest": "^24.9.0",
"reactifex": "^1.1.1"
"@edx/frontend-build": "5.0.6",
"@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",
"glob": "7.1.6",
"husky": "3.1.0",
"jest": "24.9.0",
"reactifex": "1.1.1",
"rosie": "2.0.1"
}
}

View File

@@ -1,7 +1,7 @@
<!doctype html>
<html lang="en-us">
<head>
<title>Course | edX</title>
<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" />

View File

@@ -0,0 +1,8 @@
/* eslint-disable import/prefer-default-export */
import { createSelector } from 'reselect';
export const activeCourseSelector = createSelector(
(state) => state.models.courses || {},
(state) => state.activeCourse.courseId,
(coursesById, courseId) => (coursesById[courseId] ? coursesById[courseId] : null),
);

View File

@@ -0,0 +1,44 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const COURSE_LOADING = 'loading';
export const COURSE_LOADED = 'loaded';
export const COURSE_FAILED = 'failed';
export const COURSE_DENIED = 'denied';
const slice = createSlice({
name: 'activeCourse',
initialState: {
courseStatus: COURSE_LOADING,
courseId: null,
},
reducers: {
fetchCourseRequest: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = COURSE_LOADING;
},
fetchCourseSuccess: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = COURSE_LOADED;
},
fetchCourseFailure: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = COURSE_FAILED;
},
fetchCourseDenied: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = COURSE_DENIED;
},
},
});
export const {
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,
fetchCourseDenied,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,12 @@
export { activeCourseSelector } from './data/selectors';
export {
reducer,
COURSE_LOADING,
COURSE_LOADED,
COURSE_FAILED,
COURSE_DENIED,
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,
fetchCourseDenied,
} from './data/slice';

View File

@@ -0,0 +1,24 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
function AccessExpirationAlert({ payload }) {
const {
rawHtml,
} = payload;
return rawHtml && (
<Alert type={ALERT_TYPES.INFO}>
{/* eslint-disable-next-line react/no-danger */}
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
</Alert>
);
}
AccessExpirationAlert.propTypes = {
payload: PropTypes.shape({
rawHtml: PropTypes.string.isRequired,
}).isRequired,
};
export default AccessExpirationAlert;

View File

@@ -0,0 +1,21 @@
import React, { useMemo } from 'react';
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]);
useAlert(isVisible, {
code: 'clientAccessExpirationAlert',
payload,
topic,
});
return { clientAccessExpirationAlert: AccessExpirationAlert };
}
export default useAccessExpirationAlert;

View File

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

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import messages from './messages';
import { useEnrollClickHandler } from './hooks';
function EnrollmentAlert({ intl, payload }) {
const {
canEnroll,
courseId,
extraText,
isStaff,
} = payload;
const { enrollClickHandler, loading } = useEnrollClickHandler(
courseId,
intl.formatMessage(messages.success),
);
let text = intl.formatMessage(messages.alert);
let type = ALERT_TYPES.ERROR;
if (isStaff) {
text = intl.formatMessage(messages.staffAlert);
type = ALERT_TYPES.INFO;
} else if (extraText) {
text = `${text} ${extraText}`;
}
const button = canEnroll && (
<Button disabled={loading} className="btn-link p-0 border-0 align-top" onClick={enrollClickHandler}>
{intl.formatMessage(messages.enroll)}
</Button>
);
return (
<Alert type={type}>
{text}
{' '}
{button}
{' '}
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
</Alert>
);
}
EnrollmentAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
canEnroll: PropTypes.bool,
courseId: PropTypes.string,
extraText: PropTypes.string,
isStaff: PropTypes.bool,
}).isRequired,
};
export default injectIntl(EnrollmentAlert);

View File

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

View File

@@ -0,0 +1,51 @@
/* eslint-disable import/prefer-default-export */
import React, {
useContext, useState, useCallback,
} from 'react';
import { UserMessagesContext, ALERT_TYPES, useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
import { postCourseEnrollment } from './data/api';
const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert'));
export function useEnrollmentAlert(courseId) {
const course = useModel('courses', courseId);
const outline = useModel('outline', courseId);
const isVisible = course && course.isEnrolled !== undefined && !course.isEnrolled;
useAlert(isVisible, {
code: 'clientEnrollmentAlert',
payload: {
canEnroll: outline.enrollAlert.canEnroll,
courseId,
extraText: outline.enrollAlert.extraText,
isStaff: course.isStaff,
},
topic: 'outline',
});
return { clientEnrollmentAlert: EnrollmentAlert };
}
export function useEnrollClickHandler(courseId, successText) {
const [loading, setLoading] = useState(false);
const { addFlash } = useContext(UserMessagesContext);
const enrollClickHandler = useCallback(() => {
setLoading(true);
postCourseEnrollment(courseId).then(() => {
addFlash({
dismissible: true,
flash: true,
text: successText,
type: ALERT_TYPES.SUCCESS,
topic: 'course',
});
setLoading(false);
global.location.reload();
});
}, [courseId]);
return { enrollClickHandler, loading };
}

View File

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

View File

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

View File

@@ -3,13 +3,13 @@ import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import Alert from '../user-messages/Alert';
import { Alert } from '../../generic/user-messages';
import messages from './messages';
function LogistrationAlert({ intl }) {
const signIn = (
<a href={`${getLoginRedirectUrl(global.location.href)}`}>
{intl.formatMessage(messages['learning.logistration.login'])}
{intl.formatMessage(messages.login)}
</a>
);
@@ -17,7 +17,7 @@ function LogistrationAlert({ intl }) {
// This is complicated by the fact that we don't have a REGISTER_URL env variable available.
const register = (
<a href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}>
{intl.formatMessage(messages['learning.logistration.register'])}
{intl.formatMessage(messages.register)}
</a>
);

View File

@@ -0,0 +1,20 @@
/* eslint-disable import/prefer-default-export */
import React, { useContext } from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
const LogistrationAlert = React.lazy(() => import('./LogistrationAlert'));
export function useLogistrationAlert() {
const { authenticatedUser } = useContext(AppContext);
const isVisible = authenticatedUser === null;
useAlert(isVisible, {
code: 'clientLogistrationAlert',
topic: 'outline',
dismissible: false,
type: ALERT_TYPES.ERROR,
});
return { clientLogistrationAlert: LogistrationAlert };
}

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
function OfferAlert({ payload }) {
const {
rawHtml,
} = payload;
return rawHtml && (
<Alert type={ALERT_TYPES.INFO}>
{/* eslint-disable-next-line react/no-danger */}
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
</Alert>
);
}
OfferAlert.propTypes = {
payload: PropTypes.shape({
rawHtml: PropTypes.string.isRequired,
}).isRequired,
};
export default OfferAlert;

View File

@@ -0,0 +1,19 @@
import React 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.
useAlert(isVisible, {
code: 'clientOfferAlert',
topic,
payload: { rawHtml },
});
return { clientOfferAlert: OfferAlert };
}
export default useOfferAlert;

View File

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

View File

@@ -1,17 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import classNames from 'classnames';
import messages from './messages';
import Tabs from '../tabs/Tabs';
import Tabs from '../generic/tabs/Tabs';
function CourseTabsNavigation({
activeTabSlug, tabs, intl,
activeTabSlug, className, tabs, intl,
}) {
return (
<div className="course-tabs-navigation">
<div className={classNames('course-tabs-navigation', className)}>
<div className="container-fluid">
<Tabs
className="nav-underline-tabs"
@@ -21,7 +20,7 @@ function CourseTabsNavigation({
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
href={`${getConfig().LMS_BASE_URL}${url}`}
href={url}
>
{title}
</a>
@@ -34,9 +33,9 @@ function CourseTabsNavigation({
CourseTabsNavigation.propTypes = {
activeTabSlug: PropTypes.string,
className: PropTypes.string,
tabs: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string.isRequired,
priority: PropTypes.number.isRequired,
slug: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
})).isRequired,
@@ -45,6 +44,7 @@ CourseTabsNavigation.propTypes = {
CourseTabsNavigation.defaultProps = {
activeTabSlug: undefined,
className: null,
};
export default injectIntl(CourseTabsNavigation);

View File

@@ -68,7 +68,13 @@ export default function Header({
}
Header.propTypes = {
courseOrg: PropTypes.string.isRequired,
courseNumber: PropTypes.string.isRequired,
courseTitle: PropTypes.string.isRequired,
courseOrg: PropTypes.string,
courseNumber: PropTypes.string,
courseTitle: PropTypes.string,
};
Header.defaultProps = {
courseOrg: null,
courseNumber: null,
courseTitle: null,
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('courseHomeMetadata')
.sequence(
'course_id',
(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,
})
.attr('tabs', ['courseTabs', 'host'], (courseTabs, host) => courseTabs.map(
tab => ({
tab_id: tab.slug,
title: tab.title,
url: `${host}${tab.url}`,
}),
));

View File

@@ -0,0 +1,27 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('datesTabData')
.attrs({
dates_banner_info: {
content_type_gating_enabled: false,
missed_gated_content: false,
missed_deadlines: false,
},
course_date_blocks: [
{
assigment_type: 'Homework',
date: '2013-02-05T05:00:00Z',
date_type: 'course-start-date',
description: '',
learner_has_access: true,
link: '',
title: 'Course Starts',
extraInfo: '',
},
],
missed_deadlines: false,
missed_gated_content: false,
learner_is_full_access: true,
user_timezone: null,
verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5',
});

View File

@@ -0,0 +1,3 @@
import './courseHomeMetadata.factory';
import './datesTabData.factory';
import './outlineTabData.factory';

View File

@@ -0,0 +1,25 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import buildSimpleCourseBlocks from '../../../courseware/data/__factories__/courseBlocks.factory';
Factory.define('outlineTabData')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
.option('host', 'http://localhost:18000')
.attr('course_expired_html', [], () => '<div>Course expired</div>')
.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>');

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

@@ -0,0 +1,105 @@
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';
function normalizeCourseHomeCourseMetadata(metadata) {
const data = camelCaseObject(metadata);
return {
...data,
tabs: data.tabs.map(tab => ({
slug: tab.tabId,
title: tab.title,
url: tab.url,
})),
};
}
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);
}
export async function getDatesTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
return {};
}
throw error;
}
}
export async function getProgressTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
return {};
}
throw error;
}
}
export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
let { tabData } = {};
try {
tabData = await getAuthenticatedHttpClient().get(url);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
return {};
}
throw error;
}
const {
data,
} = tabData;
const courseBlocks = normalizeBlocks(courseId, data.course_blocks.blocks);
const courseExpiredHtml = data.course_expired_html;
const courseTools = camelCaseObject(data.course_tools);
const datesWidget = camelCaseObject(data.dates_widget);
const enrollAlert = camelCaseObject(data.enroll_alert);
const handoutsHtml = data.handouts_html;
const offerHtml = data.offer_html;
const welcomeMessageHtml = data.welcome_message_html;
return {
courseBlocks,
courseExpiredHtml,
courseTools,
datesWidget,
enrollAlert,
handoutsHtml,
offerHtml,
welcomeMessageHtml,
};
}
export async function postCourseDeadlines(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`);
await getAuthenticatedHttpClient().post(url.href, { course_key: courseId });
}
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);
}

View File

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

View File

@@ -0,0 +1,183 @@
import { Factory } from 'rosie';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import { COURSE_LOADED, COURSE_LOADING, COURSE_FAILED } from '../../active-course';
import initializeMockApp from '../../setupTest';
import initializeStore from '../../store';
import executeThunk from '../../utils';
import * as thunks from './thunks';
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}`;
let store;
beforeEach(() => {
axiosMock.reset();
loggingService.logError.mockReset();
store = initializeStore();
});
it('Should initialize store', () => {
expect(store.getState().activeCourse.courseId).toBeNull();
expect(store.getState().activeCourse.courseStatus).toEqual(COURSE_LOADING);
});
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();
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().activeCourse.courseStatus).toEqual(COURSE_FAILED);
});
it('Should fetch, normalize, and save metadata', async () => {
const datesTabData = Factory.build('datesTabData');
const datesUrl = `${datesBaseUrl}/${courseId}`;
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(datesUrl).reply(200, datesTabData);
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
const state = store.getState();
expect(state.activeCourse.courseStatus).toEqual(COURSE_LOADED);
expect(state.activeCourse.courseId).toEqual(courseId);
expect(state.courseHome.displayResetDatesToast).toBe(false);
// Validate course
const course = state.models.courses[courseId];
const expectedFieldCount = Object.keys(course).length;
// If this breaks, you should consider adding assertions below for the new data. If it's not
// an "interesting" addition, just bump the number anyway.
expect(expectedFieldCount).toBe(9);
expect(course.title).toEqual(courseHomeMetadata.title);
// Representative sample of data that proves data normalization and ingestion happened.
expect(course.id).toEqual(courseId);
expect(course.isStaff).toBe(courseHomeMetadata.is_staff);
expect(course.number).toEqual(courseHomeMetadata.number);
expect(Array.isArray(course.tabs)).toBe(true);
expect(course.tabs.length).toBe(5); // Weak assertion, but proves the array made it through.
// This proves the tab type came through as a modelType. We don't need to assert much else
// here because the shape of this data is not passed through any sort of normalization scheme,
// it just gets camelCased.
const dates = state.models.dates[courseId];
expect(dates.id).toEqual(courseId);
expect(dates.verifiedUpgradeLink).toBe(datesTabData.verified_upgrade_link);
});
});
describe('Test fetchOutlineTab', () => {
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();
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().activeCourse.courseStatus).toEqual(COURSE_FAILED);
});
it('Should fetch, normalize, and save metadata', async () => {
const outlineTabData = Factory.build('outlineTabData', { courseId });
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
const state = store.getState();
expect(state.activeCourse.courseStatus).toEqual(COURSE_LOADED);
expect(state.courseHome.displayResetDatesToast).toBe(false);
// Validate course
const course = state.models.courses[courseId];
const expectedFieldCount = Object.keys(course).length;
// If this breaks, you should consider adding assertions below for the new data. If it's not
// an "interesting" addition, just bump the number anyway.
expect(expectedFieldCount).toBe(9);
expect(course.title).toEqual(courseHomeMetadata.title);
// Representative sample of data that proves data normalization and ingestion happened.
expect(course.id).toEqual(courseId);
expect(course.isStaff).toBe(courseHomeMetadata.is_staff);
expect(course.number).toEqual(courseHomeMetadata.number);
expect(Array.isArray(course.tabs)).toBe(true);
expect(course.tabs.length).toBe(5); // Weak assertion, but proves the array made it through.
// This proves the tab type came through as a modelType. We don't need to assert much else
// here because the shape of this data is not passed through any sort of normalization scheme,
// it just gets camelCased.
const outline = state.models.outline[courseId];
expect(outline.id).toEqual(courseId);
expect(outline.handoutsHtml).toBe(outlineTabData.handouts_html);
});
});
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 getTabDataMock = jest.fn(() => ({
type: 'MOCK_ACTION',
}));
await executeThunk(thunks.resetDeadlines(courseId, getTabDataMock), store.dispatch);
expect(axiosMock.history.post[0].url).toEqual(resetUrl);
expect(axiosMock.history.post[0].data).toEqual(`{"course_key":"${courseId}"}`);
expect(getTabDataMock).toHaveBeenCalledWith(courseId);
});
});
describe('Test dismissWelcomeMessage', () => {
it('Should dismiss welcome message', async () => {
const dismissUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`;
axiosMock.onPost(dismissUrl).reply(201);
await executeThunk(thunks.dismissWelcomeMessage(courseId), store.dispatch);
expect(axiosMock.history.post[0].url).toEqual(dismissUrl);
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}"}`);
});
});
});

View File

@@ -0,0 +1,22 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
const slice = createSlice({
name: 'course-home',
initialState: {
displayResetDatesToast: false,
},
reducers: {
toggleResetDatesToast: (state, { payload }) => {
state.displayResetDatesToast = payload.displayResetDatesToast;
},
},
});
export const {
toggleResetDatesToast,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,97 @@
import { logError } from '@edx/frontend-platform/logging';
import {
getCourseHomeCourseMetadata,
getDatesTabData,
getOutlineTabData,
getProgressTabData,
postCourseDeadlines,
postDismissWelcomeMessage,
postRequestCert,
} from './api';
import {
addModel,
} from '../../generic/model-store';
import {
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,
} from '../../active-course';
import {
toggleResetDatesToast,
} from './slice';
export function fetchTab(courseId, tab, getTabData) {
return async (dispatch) => {
dispatch(fetchCourseRequest({ courseId }));
Promise.allSettled([
getCourseHomeCourseMetadata(courseId),
getTabData(courseId),
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
const fetchedTabData = tabDataResult.status === 'fulfilled';
if (fetchedCourseHomeCourseMetadata) {
dispatch(addModel({
modelType: 'courses',
model: {
id: courseId,
...courseHomeCourseMetadataResult.value,
},
}));
} else {
logError(courseHomeCourseMetadataResult.reason);
}
if (fetchedTabData) {
dispatch(addModel({
modelType: tab,
model: {
id: courseId,
...tabDataResult.value,
},
}));
} else {
logError(tabDataResult.reason);
}
if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
dispatch(fetchCourseSuccess({ courseId }));
} else {
dispatch(fetchCourseFailure({ courseId }));
}
});
};
}
export function fetchDatesTab(courseId) {
return fetchTab(courseId, 'dates', getDatesTabData);
}
export function fetchProgressTab(courseId) {
return fetchTab(courseId, 'progress', getProgressTabData);
}
export function fetchOutlineTab(courseId) {
return fetchTab(courseId, 'outline', getOutlineTabData);
}
export function resetDeadlines(courseId, getTabData) {
return async (dispatch) => {
postCourseDeadlines(courseId).then(() => {
dispatch(getTabData(courseId));
dispatch(toggleResetDatesToast({ displayResetDatesToast: true }));
});
};
}
export function dismissWelcomeMessage(courseId) {
return async () => postDismissWelcomeMessage(courseId);
}
export function requestCert(courseId) {
return async () => postRequestCert(courseId);
}

View File

@@ -0,0 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
function DatesBanner(props) {
const {
intl,
name,
bannerClickHandler,
} = 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'}>
<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}>
{intl.formatMessage(messages[`datesBanner.${name}.button`])}
</button>
</div>
)}
</div>
</div>
);
}
DatesBanner.propTypes = {
intl: intlShape.isRequired,
name: PropTypes.string.isRequired,
bannerClickHandler: PropTypes.func,
};
DatesBanner.defaultProps = {
bannerClickHandler: null,
};
export default injectIntl(DatesBanner);

View File

@@ -0,0 +1,80 @@
import React from 'react';
import PropTypes from 'prop-types';
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;
const {
courseId,
} = useSelector(state => state.activeCourse);
const {
courseDateBlocks,
datesBannerInfo,
hasEnded,
} = useModel(model, courseId);
const {
contentTypeGatingEnabled,
missedDeadlines,
missedGatedContent,
verifiedUpgradeLink,
} = datesBannerInfo;
const {
isSelfPaced,
} = useModel('courses', courseId);
const dispatch = useDispatch();
const hasDeadlines = courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
const upgradeToCompleteGraded = model === 'dates' && contentTypeGatingEnabled && !missedDeadlines;
const upgradeToReset = !upgradeToCompleteGraded && missedDeadlines && missedGatedContent;
const resetDates = !upgradeToCompleteGraded && missedDeadlines && !missedGatedContent;
const datesBanners = [
{
name: 'datesTabInfoBanner',
shouldDisplay: model === 'dates' && hasDeadlines && !missedDeadlines && isSelfPaced,
},
{
name: 'upgradeToCompleteGradedBanner',
shouldDisplay: upgradeToCompleteGraded,
clickHandler: () => window.location.replace(verifiedUpgradeLink),
},
{
name: 'upgradeToResetBanner',
shouldDisplay: upgradeToReset,
clickHandler: () => window.location.replace(verifiedUpgradeLink),
},
{
name: 'resetDatesBanner',
shouldDisplay: resetDates,
clickHandler: () => dispatch(resetDeadlines(courseId, fetchDatesTab)),
},
];
return (
<>
{!hasEnded && datesBanners.map((banner) => banner.shouldDisplay && (
<DatesBanner
name={banner.name}
bannerClickHandler={banner.clickHandler}
key={banner.name}
/>
))}
</>
);
}
DatesBannerContainer.propTypes = {
model: PropTypes.string.isRequired,
};
export default DatesBannerContainer;

View File

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

View File

@@ -0,0 +1,66 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'datesBanner.datesTabInfoBanner.header': {
id: 'datesBanner.datesTabInfoBanner.header',
defaultMessage: "We've built a suggested schedule to help you stay on track. ",
description: 'Strong text in Dates Tab Info Banner',
},
'datesBanner.datesTabInfoBanner.body': {
id: 'datesBanner.datesTabInfoBanner.body',
defaultMessage: `But don't worry—it's flexible so you can learn at your own pace. If you happen to fall behind on
our suggested dates, you'll be able to adjust them to keep yourself on track.`,
description: 'Body in Dates Tab Info Banner',
},
'datesBanner.upgradeToCompleteGradedBanner.header': {
id: 'datesBanner.upgradeToCompleteGradedBanner.header',
defaultMessage: 'You are auditing this course, ',
description: 'Strong text in Upgrade To Complete Graded Banner',
},
'datesBanner.upgradeToCompleteGradedBanner.body': {
id: 'datesBanner.upgradeToCompleteGradedBanner.body',
defaultMessage: `which means that you are unable to participate in graded assignments. To complete graded
assignments as part of this course, you can upgrade today.`,
description: 'Body in Upgrade To Complete Graded Banner',
},
'datesBanner.upgradeToCompleteGradedBanner.button': {
id: 'datesBanner.upgradeToCompleteGradedBanner.button',
defaultMessage: 'Upgrade now',
description: 'Button in Upgrade To Complete Graded Banner',
},
'datesBanner.upgradeToResetBanner.header': {
id: 'datesBanner.upgradeToResetBanner.header',
defaultMessage: 'You are auditing this course, ',
description: 'Strong text in Upgrade To Reset Banner',
},
'datesBanner.upgradeToResetBanner.body': {
id: 'datesBanner.upgradeToResetBanner.body',
defaultMessage: `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.`,
description: 'Body in Upgrade To Reset Banner',
},
'datesBanner.upgradeToResetBanner.button': {
id: 'datesBanner.upgradeToResetBanner.button',
defaultMessage: 'Upgrade to shift due dates',
description: 'Button in Upgrade To Reset Banner',
},
'datesBanner.resetDatesBanner.header': {
id: 'datesBanner.resetDatesBanner.header',
defaultMessage: 'It looks like you missed some important deadlines based on our suggested schedule. ',
description: 'Strong text in Reset Dates Banner',
},
'datesBanner.resetDatesBanner.body': {
id: 'datesBanner.resetDatesBanner.body',
defaultMessage: `To keep yourself on track, you can update this schedule and shift the past due assignments into
the future. Dont worry—you wont lose any of the progress youve made when you shift your due dates.`,
description: 'Body in Reset Dates Banner',
},
'datesBanner.resetDatesBanner.button': {
id: 'datesBanner.resetDatesBanner.button',
defaultMessage: 'Shift due dates',
description: 'Button in Reset Dates Banner',
},
});
export default messages;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
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)}>
{children}
</span>
);
}
Badge.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
};
Badge.defaultProps = {
children: null,
className: null,
};

View File

@@ -0,0 +1,3 @@
.dates-badge {
font-size: 0.75rem;
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import Timeline from './Timeline';
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
function DatesTab({ intl }) {
return (
<>
<div role="heading" aria-level="1" className="h4 my-3">
{intl.formatMessage(messages.title)}
</div>
<DatesBannerContainer model="dates" />
<Timeline />
</>
);
}
DatesTab.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(DatesTab);

View File

@@ -0,0 +1,97 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { FormattedDate, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useModel } from '../../generic/model-store';
import { getBadgeListAndColor } from './badgelist';
import { isLearnerAssignment } from './utils';
function Day({
date, first, intl, items, last,
}) {
const {
courseId,
} = useSelector(state => state.activeCourse);
const {
userTimezone,
} = useModel('dates', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const { color, badges } = getBadgeListAndColor(date, intl, null, items);
return (
<li className="dates-day pb-4">
{/* Top Line */}
{!first && <div className="dates-line-top border-1 border-left border-gray-900 bg-gray-900" />}
{/* Dot */}
<div className={classNames(color, 'dates-dot border border-gray-900')} />
{/* Bottom Line */}
{!last && <div className="dates-line-bottom border-1 border-left border-gray-900 bg-gray-900" />}
{/* Content */}
<div className="d-inline-block ml-3 pl-2">
<div className="mb-1">
<p className="d-inline text-dark-500 font-weight-bold">
<FormattedDate
value={date}
day="numeric"
month="short"
weekday="short"
year="numeric"
{...timezoneFormatArgs}
/>
</p>
{badges}
</div>
{items.map((item) => {
const { badges: itemBadges } = getBadgeListAndColor(date, intl, item, items);
const showLink = item.link && isLearnerAssignment(item);
const title = showLink ? (<u><a href={item.link} className="text-reset">{item.title}</a></u>) : item.title;
const available = item.learnerHasAccess && (item.link || !isLearnerAssignment(item));
const textColor = available ? 'text-dark-500' : 'text-dark-200';
return (
<div key={item.title + item.date} className={textColor}>
<div>
<span className="font-weight-bold small mt-1">
{item.assignmentType && `${item.assignmentType}: `}{title}
</span>
{itemBadges}
</div>
{item.description && <div className="small mb-2">{item.description}</div>}
{item.extraInfo && <div className="small mb-2">{item.extraInfo}</div>}
</div>
);
})}
</div>
</li>
);
}
Day.propTypes = {
date: PropTypes.objectOf(Date).isRequired,
first: PropTypes.bool,
intl: intlShape.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
date: PropTypes.string,
dateType: PropTypes.string,
description: PropTypes.string,
dueNext: PropTypes.bool,
learnerHasAccess: PropTypes.bool,
link: PropTypes.string,
title: PropTypes.string,
})).isRequired,
last: PropTypes.bool,
};
Day.defaultProps = {
first: false,
last: false,
};
export default injectIntl(Day);

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,34 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
completed: {
id: 'learning.dates.badge.completed',
defaultMessage: 'Completed',
},
dueNext: {
id: 'learning.dates.badge.dueNext',
defaultMessage: 'Due Next',
},
pastDue: {
id: 'learning.dates.badge.pastDue',
defaultMessage: 'Past Due',
},
title: {
id: 'learning.dates.title',
defaultMessage: 'Important Dates',
},
today: {
id: 'learning.dates.badge.today',
defaultMessage: 'Today',
},
unreleased: {
id: 'learning.dates.badge.unreleased',
defaultMessage: 'Not Yet Released',
},
verifiedOnly: {
id: 'learning.dates.badge.verifiedOnly',
defaultMessage: 'Verified Only',
},
});
export default messages;

View File

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

View File

@@ -1 +1,2 @@
export { default } from './CourseHomeContainer';
/* eslint-disable import/prefer-default-export */
export { reducer } from './data';

View File

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

View File

@@ -0,0 +1,62 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
import { FormattedDate } from '@edx/frontend-platform/i18n';
import React from 'react';
import PropTypes from 'prop-types';
import { isLearnerAssignment } from '../dates-tab/utils';
import './DateSummary.scss';
export default function DateSummary({
dateBlock,
userTimezone,
}) {
const linkedTitle = dateBlock.link && isLearnerAssignment(dateBlock);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<section className="container p-0 mb-3">
<div className="row">
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" style={{ width: '20px' }} />
<div className="ml-2 font-weight-bold">
<FormattedDate
value={dateBlock.date}
day="numeric"
month="short"
weekday="short"
year="numeric"
{...timezoneFormatArgs}
/>
</div>
</div>
<div className="row ml-4 px-2">
<div className="date-summary-text">
{linkedTitle
&& <div className="font-weight-bold mt-2"><a href={dateBlock.link}>{dateBlock.title}</a></div>}
{!linkedTitle
&& <div className="font-weight-bold mt-2">{dateBlock.title}</div>}
</div>
{dateBlock.description
&& <div className="date-summary-text m-0 mt-1">{dateBlock.description}</div>}
{!linkedTitle && dateBlock.link
&& <a href={dateBlock.link} className="description-link">{dateBlock.linkText}</a>}
</div>
</section>
);
}
DateSummary.propTypes = {
dateBlock: PropTypes.shape({
date: PropTypes.string.isRequired,
dateType: PropTypes.string,
description: PropTypes.string,
link: PropTypes.string,
linkText: PropTypes.string,
title: PropTypes.string.isRequired,
learnerHasAccess: PropTypes.bool,
}).isRequired,
userTimezone: PropTypes.string,
};
DateSummary.defaultProps = {
userTimezone: null,
};

View File

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

View File

@@ -0,0 +1,39 @@
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
export default function LmsHtmlFragment({ 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>
`;
const iframe = useRef(null);
function handleLoad() {
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
}
return (
<iframe
className="w-100 border-0"
onLoad={handleLoad}
ref={iframe}
referrerPolicy="origin"
scrolling="no"
srcDoc={wholePage}
title={title}
{...rest}
/>
);
}
LmsHtmlFragment.propTypes = {
html: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { Button } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates';
import CourseHandouts from './widgets/CourseHandouts';
import CourseTools from './widgets/CourseTools';
import messages from './messages';
import Section from './Section';
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 { useModel } from '../../generic/model-store';
import WelcomeMessage from './widgets/WelcomeMessage';
function OutlineTab({ intl }) {
const {
courseId,
} = useSelector(state => state.activeCourse);
const {
title,
start,
end,
enrollmentStart,
enrollmentEnd,
enrollmentMode,
isEnrolled,
} = useModel('courses', courseId);
const {
courseBlocks: {
courses,
sections,
},
courseExpiredHtml,
offerHtml,
} = useModel('outline', courseId);
// Above the tab alerts (appearing in the order listed here)
const logistrationAlert = useLogistrationAlert();
const enrollmentAlert = useEnrollmentAlert(courseId);
// 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 courseStartAlert = useCourseStartAlert(courseId);
const courseEndAlert = useCourseEndAlert(courseId);
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
const rootCourseId = Object.keys(courses)[0];
const { sectionIds } = courses[rootCourseId];
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>
</div>
<div className="row">
<div className="col col-8">
<WelcomeMessage courseId={courseId} />
<AlertList
topic="outline-course-alerts"
className="mb-3"
customAlerts={{
...accessExpirationAlert,
...certificateAvailableAlert,
...courseEndAlert,
...courseStartAlert,
...offerAlert,
}}
/>
{sectionIds.map((sectionId) => (
<Section
key={sectionId}
courseId={courseId}
title={sections[sectionId].title}
sequenceIds={sections[sectionId].sequenceIds}
/>
))}
</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}
/>
</div>
</div>
</>
);
}
OutlineTab.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(OutlineTab);

View File

@@ -4,11 +4,15 @@ import { Collapsible } from '@edx/paragon';
import { faChevronRight, faChevronDown } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import SequenceLink from './SequenceLink';
import { useModel } from '../model-store';
import { useModel } from '../../generic/model-store';
export default function Section({ courseId, title, sequenceIds }) {
const {
courseBlocks: {
sequences,
},
} = useModel('outline', courseId);
export default function Section({ id, courseId }) {
const section = useModel('sections', id);
const { title, sequenceIds } = section;
return (
<Collapsible.Advanced className="collapsible-card mb-2">
<Collapsible.Trigger className="collapsible-trigger d-flex align-items-start">
@@ -31,6 +35,7 @@ export default function Section({ id, courseId }) {
key={sequenceId}
id={sequenceId}
courseId={courseId}
title={sequences[sequenceId].title}
/>
))}
</Collapsible.Body>
@@ -39,6 +44,7 @@ export default function Section({ id, courseId }) {
}
Section.propTypes = {
id: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
sequenceIds: PropTypes.arrayOf(PropTypes.string).isRequired,
};

View File

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

View File

@@ -0,0 +1,62 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, FormattedRelative } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
function CertificateAvailableAlert({ payload }) {
const {
certDate,
username,
userTimezone,
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<Alert type={ALERT_TYPES.INFO}>
<strong>
<FormattedMessage
id="learning.outline.alert.cert.title"
defaultMessage="We are working on generating course certificates."
/>
</strong>
<br />
<FormattedMessage
id="learning.outline.alert.cert.when"
defaultMessage="If you have earned a certificate, you will be able to access it {timeRemaining}. You will also be able to view your certificates on your {profileLink}."
values={{
profileLink: (
<Hyperlink
destination={`${getConfig().LMS_BASE_URL}/u/${username}`}
>
<FormattedMessage
id="learning.outline.alert.cert.profile"
defaultMessage="Learner Profile"
/>
</Hyperlink>
),
timeRemaining: (
<FormattedRelative
key="timeRemaining"
value={certDate}
{...timezoneFormatArgs}
/>
),
}}
/>
</Alert>
);
}
CertificateAvailableAlert.propTypes = {
payload: PropTypes.shape({
certDate: PropTypes.string,
username: PropTypes.string,
userTimezone: PropTypes.string,
}).isRequired,
};
export default CertificateAvailableAlert;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useAlert } from '../../../../generic/user-messages';
import { useModel } from '../../../../generic/model-store';
const CertificateAvailableAlert = React.lazy(() => import('./CertificateAvailableAlert'));
function useCertificateAvailableAlert(courseId) {
const {
isEnrolled,
} = useModel('courses', courseId);
const {
datesWidget: {
courseDateBlocks,
userTimezone,
},
} = useModel('outline', courseId);
const { username } = getAuthenticatedUser();
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
useAlert(isVisible, {
code: 'clientCertificateAvailableAlert',
payload: {
certDate: certBlock && certBlock.date,
username,
userTimezone,
},
topic: 'outline-course-alerts',
});
return {
clientCertificateAvailableAlert: CertificateAvailableAlert,
};
}
export default useCertificateAvailableAlert;

View File

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

View File

@@ -0,0 +1,98 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedDate,
FormattedMessage,
FormattedRelative,
FormattedTime,
} from '@edx/frontend-platform/i18n';
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
function CourseEndAlert({ payload }) {
const {
delta,
description,
endDate,
userTimezone,
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const timeRemaining = (
<FormattedRelative
key="timeRemaining"
value={endDate}
{...timezoneFormatArgs}
/>
);
let msg;
if (delta < DAY_MS) {
const courseEndTime = (
<FormattedTime
key="courseEndTime"
day="numeric"
month="short"
year="numeric"
hour12={false}
timeZoneName="short"
value={endDate}
{...timezoneFormatArgs}
/>
);
msg = (
<FormattedMessage
id="learning.outline.alert.end.short"
defaultMessage="This course is ending {timeRemaining} at {courseEndTime}."
description="Used when the time remaining is less than a day away."
values={{
courseEndTime,
timeRemaining,
}}
/>
);
} else {
const courseEndDate = (
<FormattedDate
key="courseEndDate"
day="numeric"
month="short"
year="numeric"
value={endDate}
{...timezoneFormatArgs}
/>
);
msg = (
<FormattedMessage
id="learning.outline.alert.end.long"
defaultMessage="This course is ending {timeRemaining} on {courseEndDate}."
description="Used when the time remaining is more than a day away."
values={{
courseEndDate,
timeRemaining,
}}
/>
);
}
return (
<Alert type={ALERT_TYPES.INFO}>
<strong>{msg}</strong><br />
{description}
</Alert>
);
}
CourseEndAlert.propTypes = {
payload: PropTypes.shape({
delta: PropTypes.number,
description: PropTypes.string,
endDate: PropTypes.string,
userTimezone: PropTypes.string,
}).isRequired,
};
export default CourseEndAlert;

View File

@@ -0,0 +1,41 @@
/* eslint-disable import/prefer-default-export */
import React from 'react';
import { useAlert } from '../../../../generic/user-messages';
import { useModel } from '../../../../generic/model-store';
const CourseEndAlert = React.lazy(() => import('./CourseEndAlert'));
// period of time (in ms) before end of course during which we alert
const WARNING_PERIOD_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
export function useCourseEndAlert(courseId) {
const {
isEnrolled,
} = useModel('courses', courseId);
const {
datesWidget: {
courseDateBlocks,
userTimezone,
},
} = useModel('outline', courseId);
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
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;
useAlert(isVisible, {
code: 'clientCourseEndAlert',
payload: {
delta,
description: endBlock && endBlock.description,
endDate: endBlock && endBlock.date,
userTimezone,
},
topic: 'outline-course-alerts',
});
return {
clientCourseEndAlert: CourseEndAlert,
};
}

View File

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

View File

@@ -0,0 +1,97 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedDate,
FormattedMessage,
FormattedRelative,
FormattedTime,
} from '@edx/frontend-platform/i18n';
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
function CourseStartAlert({ payload }) {
const {
delta,
startDate,
userTimezone,
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const timeRemaining = (
<FormattedRelative
key="timeRemaining"
value={startDate}
{...timezoneFormatArgs}
/>
);
if (delta < DAY_MS) {
return (
<Alert type={ALERT_TYPES.INFO}>
<FormattedMessage
id="learning.outline.alert.start.short"
defaultMessage="Course starts {timeRemaining} at {courseStartTime}."
description="Used when the time remaining is less than a day away."
values={{
courseStartTime: (
<FormattedTime
key="courseStartTime"
day="numeric"
month="short"
year="numeric"
hour12={false}
timeZoneName="short"
value={startDate}
{...timezoneFormatArgs}
/>
),
timeRemaining,
}}
/>
</Alert>
);
}
return (
<Alert type={ALERT_TYPES.INFO}>
<strong>
<FormattedMessage
id="learning.outline.alert.end.long"
defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
description="Used when the time remaining is more than a day away."
values={{
courseStartDate: (
<FormattedDate
key="courseStartDate"
day="numeric"
month="short"
year="numeric"
value={startDate}
{...timezoneFormatArgs}
/>
),
timeRemaining,
}}
/>
</strong>
<br />
<FormattedMessage
id="learning.outline.alert.end.calendar"
defaultMessage="Dont forget to add a calendar reminder!"
/>
</Alert>
);
}
CourseStartAlert.propTypes = {
payload: PropTypes.shape({
delta: PropTypes.number,
startDate: PropTypes.string,
userTimezone: PropTypes.string,
}).isRequired,
};
export default CourseStartAlert;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
allDates: {
id: 'learning.outline.dates.all',
defaultMessage: 'View all course dates',
},
dates: {
id: 'learning.outline.dates',
defaultMessage: 'Upcoming Dates',
},
handouts: {
id: 'learning.outline.handouts',
defaultMessage: 'Course Handouts',
},
resume: {
id: 'learning.outline.resume',
defaultMessage: 'Resume Course',
},
tools: {
id: 'learning.outline.tools',
defaultMessage: 'Course Tools',
},
welcomeMessage: {
id: 'learning.outline.welcomeMessage',
defaultMessage: 'Welcome Message',
},
welcomeMessageShowMoreButton: {
id: 'learning.outline.welcomeMessageShowMoreButton',
defaultMessage: 'Show More',
},
welcomeMessageShowLessButton: {
id: 'learning.outline.welcomeMessageShowLessButton',
defaultMessage: 'Show Less',
},
});
export default messages;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import DateSummary from '../DateSummary';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function CourseDates({ courseId, intl }) {
const {
datesWidget,
} = useModel('outline', courseId);
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}>
{intl.formatMessage(messages.allDates)}
</a>
</section>
);
}
CourseDates.propTypes = {
courseId: PropTypes.string,
intl: intlShape.isRequired,
};
CourseDates.defaultProps = {
courseId: null,
};
export default injectIntl(CourseDates);

View File

@@ -0,0 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import LmsHtmlFragment from '../LmsHtmlFragment';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function CourseHandouts({ courseId, intl }) {
const {
handoutsHtml,
} = useModel('outline', courseId);
if (!handoutsHtml) {
return null;
}
return (
<section className="mb-3">
<h4>{intl.formatMessage(messages.handouts)}</h4>
<LmsHtmlFragment
html={handoutsHtml}
title={intl.formatMessage(messages.handouts)}
/>
</section>
);
}
CourseHandouts.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(CourseHandouts);

View File

@@ -0,0 +1,73 @@
import React from 'react';
import PropTypes from 'prop-types';
import { sendTrackEvent } 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';
import {
faBookmark, faCertificate, faInfo, faCalendar, faStar,
} from '@fortawesome/free-solid-svg-icons';
import { faNewspaper } from '@fortawesome/free-regular-svg-icons';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function CourseTools({ courseId, intl }) {
const {
courseTools,
} = useModel('outline', courseId);
const logClick = (analyticsId) => {
const { administrator } = getAuthenticatedUser();
sendTrackEvent('edx.course.tool.accessed', {
course_id: courseId,
is_staff: administrator,
tool_name: analyticsId,
});
};
const renderIcon = (iconClasses) => {
switch (iconClasses) {
case 'edx.bookmarks':
return faBookmark;
case 'edx.tool.verified_upgrade':
return faCertificate;
case 'edx.tool.financial_assistance':
return faInfo;
case 'edx.calendar-sync':
return faCalendar;
case 'edx.updates':
return faNewspaper;
case 'edx.reviews':
return faStar;
default:
return null;
}
};
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>
);
}
CourseTools.propTypes = {
courseId: PropTypes.string,
intl: intlShape.isRequired,
};
CourseTools.defaultProps = {
courseId: null,
};
export default injectIntl(CourseTools);

View File

@@ -0,0 +1,68 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useDispatch } from 'react-redux';
import LmsHtmlFragment from '../LmsHtmlFragment';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import { Alert } from '../../../generic/user-messages';
import { dismissWelcomeMessage } from '../../data/thunks';
function WelcomeMessage({ courseId, intl }) {
const {
welcomeMessageHtml,
} = useModel('outline', courseId);
if (!welcomeMessageHtml) {
return null;
}
const [display, setDisplay] = useState(true);
const shortWelcomeMessageHtml = welcomeMessageHtml.length > 200 && `${welcomeMessageHtml.substring(0, 199)}...`;
const [showShortMessage, setShowShortMessage] = useState(!!shortWelcomeMessageHtml);
const dispatch = useDispatch();
return (
display && (
<Alert
type="welcome"
dismissible
onDismiss={() => {
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"
onClick={() => setShowShortMessage(!showShortMessage)}
>
{showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton)
: intl.formatMessage(messages.welcomeMessageShowLessButton)}
</button>
</div>
)
}
</Alert>
)
);
}
WelcomeMessage.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(WelcomeMessage);

View File

@@ -0,0 +1,73 @@
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 '../../courseware/course/sequence/lock-paywall/assets/edx-verified-mini-cert.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,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import Subsection from './Subsection';
export default function Chapter({
chapter,
}) {
if (chapter.displayName === 'hidden') { return null; }
const { subsections } = chapter;
return (
<section className="border-top border-light-500">
<div className="row">
<div className="lead font-weight-normal col-12 col-sm-3 my-3 border-right border-light-500">
{chapter.displayName}
</div>
<div className="col-12 col-sm-9">
{subsections.map((subsection) => (
<Subsection
key={subsection.url}
subsection={subsection}
/>
))}
</div>
</div>
</section>
);
}
Chapter.propTypes = {
chapter: PropTypes.shape({
displayName: PropTypes.string,
subsections: PropTypes.arrayOf(PropTypes.shape({
url: PropTypes.string,
})),
}).isRequired,
};

View File

@@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { FormattedDate, FormattedTime } from '@edx/frontend-platform/i18n';
import { useModel } from '../../generic/model-store';
export default function DueDateTime({
due,
}) {
const {
courseId,
} = useSelector(state => state.activeCourse);
const {
userTimezone,
} = useModel('progress', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<em className="ml-0">
due <FormattedDate
value={due}
day="numeric"
month="short"
weekday="short"
year="numeric"
{...timezoneFormatArgs}
/> <FormattedTime
value={due}
/>
</em>
);
}
DueDateTime.propTypes = {
due: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
function ProblemScores({
intl,
scoreName,
problemScores,
}) {
return (
<div className="row mt-1">
<dl className="d-flex flex-wrap small text-gray-500">
<dt className="mr-3">{intl.formatMessage(messages[`${scoreName}`])}</dt>
{problemScores.map((problem, index) => {
const key = scoreName + index;
return (
<dd className="mr-3" key={key}>{problem.earned}/{problem.possible}</dd>
);
})}
</dl>
</div>
);
}
ProblemScores.propTypes = {
intl: intlShape.isRequired,
scoreName: PropTypes.string.isRequired,
problemScores: PropTypes.arrayOf(PropTypes.shape({
possible: PropTypes.number,
earned: PropTypes.number,
id: PropTypes.string,
})).isRequired,
};
export default injectIntl(ProblemScores);

View File

@@ -0,0 +1,46 @@
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';
function ProgressTab({ intl }) {
const {
courseId,
} = useSelector(state => state.activeCourse);
const { administrator } = getAuthenticatedUser();
const {
coursewareSummary,
studioUrl,
} = useModel('progress', courseId);
return (
<section>
{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 />
{coursewareSummary.map((chapter) => (
<Chapter
key={chapter.displayName}
chapter={chapter}
/>
))}
</section>
);
}
ProgressTab.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(ProgressTab);

View File

@@ -0,0 +1,76 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import DueDateTime from './DueDateTime';
import ProblemScores from './ProblemScores';
function Subsection({
intl,
subsection,
}) {
const scoreName = subsection.graded ? 'problem' : 'practice';
const { earned, possible } = subsection.gradedTotal;
const showTotalScore = ((possible > 0) || (earned > 0)) && subsection.showGrades;
// screen reader information
const totalScoreSr = intl.formatMessage(messages.pointsEarned, { earned, total: possible });
return (
<section className="my-3 ml-3">
<div className="row">
<a className="h6" href={subsection.url}>
<div dangerouslySetInnerHTML={{ __html: subsection.displayName }} />
{showTotalScore && <span className="sr-only">{totalScoreSr}</span>}
</a>
{showTotalScore && <span className="small ml-1 mb-2">({earned}/{possible}) {subsection.percentGraded}%</span>}
</div>
<div className="row small">
{subsection.format && <div className="mr-1">{subsection.format}</div>}
{subsection.due !== null && <DueDateTime due={subsection.due} />}
</div>
{subsection.problemScores.length > 0 && subsection.showGrades && (
<ProblemScores scoreName={scoreName} problemScores={subsection.problemScores} />
)}
{subsection.problemScores.length > 0 && !subsection.showGrades && subsection.showCorrectness === 'past_due' && (
<div className="row small">{intl.formatMessage(messages[`${scoreName}HiddenUntil`])}</div>
)}
{subsection.problemScores.length > 0 && !subsection.showGrades && !(subsection.showCorrectness === 'past_due')
&& <div className="row small">{intl.formatMessage(messages[`${scoreName}Hidden`])}</div>}
{(subsection.problemScores.length === 0) && (
<div className="row small">{intl.formatMessage(messages.noScores)}</div>
)}
</section>
);
}
Subsection.propTypes = {
intl: intlShape.isRequired,
subsection: PropTypes.shape({
graded: PropTypes.bool.isRequired,
url: PropTypes.string.isRequired,
showGrades: PropTypes.bool.isRequired,
gradedTotal: PropTypes.shape({
possible: PropTypes.number,
earned: PropTypes.number,
graded: PropTypes.bool,
}).isRequired,
showCorrectness: PropTypes.string.isRequired,
due: PropTypes.string,
problemScores: PropTypes.arrayOf(PropTypes.shape({
possible: PropTypes.number,
earned: PropTypes.number,
id: PropTypes.string,
})).isRequired,
format: PropTypes.string,
// override: PropTypes.object,
displayName: PropTypes.string.isRequired,
percentGraded: PropTypes.number.isRequired,
}).isRequired,
};
export default injectIntl(Subsection);

View File

@@ -0,0 +1,63 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
problem: {
id: 'learning.progress.badge.problem',
defaultMessage: 'Problem Scores: ',
},
practice: {
id: 'learning.progress.badge.practice',
defaultMessage: 'Practice Scores: ',
},
problemHiddenUntil: {
id: 'learning.progress.badge.problemHiddenUntil',
defaultMessage: 'Problem scores are hidden until the due date.',
},
practiceHiddenUntil: {
id: 'learning.progress.badge.practiceHiddenUntil',
defaultMessage: 'Practice scores are hidden until the due date.',
},
problemHidden: {
id: 'learning.progress.badge.probHidden',
defaultMessage: 'problemlem scores are hidden.',
},
practiceHidden: {
id: 'learning.progress.badge.practiceHidden',
defaultMessage: 'Practice scores are hidden.',
},
noScores: {
id: 'learning.progress.badge.noScores',
defaultMessage: 'No problem scores in this section.',
},
pointsEarned: {
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',
},
});
export default messages;

View File

@@ -1,185 +1,256 @@
import React, { useEffect, useCallback } from 'react';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { useSelector, useDispatch } from 'react-redux';
import { connect } from 'react-redux';
import { history } from '@edx/frontend-platform';
import { getLocale } from '@edx/frontend-platform/i18n';
import { Redirect } from 'react-router';
import { createSelector } from '@reduxjs/toolkit';
import { defaultMemoize as memoize } from 'reselect';
import { useRouteMatch, Redirect } from 'react-router';
import {
fetchCourse,
fetchSequence,
} from '../data';
import {
checkBlockCompletion,
fetchCourse,
fetchSequence,
getResumeBlock,
saveSequencePosition,
} from './data/thunks';
import { useModel } from '../model-store';
SEQUENCE_LOADED,
SEQUENCE_LOADING,
SEQUENCE_FAILED,
} from './data';
import { TabPage } from '../tab-page';
import {
activeCourseSelector, COURSE_LOADED, COURSE_LOADING, COURSE_FAILED, COURSE_DENIED,
} from '../active-course';
import Course from './course';
import { handleNextSectionCelebration } from './course/celebration';
import { sequenceIdsSelector, firstSequenceIdSelector } from './data/selectors';
function useUnitNavigationHandler(courseId, sequenceId, unitId) {
const dispatch = useDispatch();
return useCallback((nextUnitId) => {
dispatch(checkBlockCompletion(courseId, sequenceId, unitId));
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
}, [courseId, sequenceId]);
}
function usePreviousSequence(sequenceId) {
const sequenceIds = useSelector(sequenceIdsSelector);
const sequences = useSelector(state => state.models.sequences);
if (!sequenceId || sequenceIds.length === 0) {
return null;
}
const sequenceIndex = sequenceIds.indexOf(sequenceId);
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
return previousSequenceId !== null ? sequences[previousSequenceId] : null;
}
function useNextSequence(sequenceId) {
const sequenceIds = useSelector(sequenceIdsSelector);
const sequences = useSelector(state => state.models.sequences);
if (!sequenceId || sequenceIds.length === 0) {
return null;
}
const sequenceIndex = sequenceIds.indexOf(sequenceId);
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
return nextSequenceId !== null ? sequences[nextSequenceId] : null;
}
function useNextSequenceHandler(courseId, sequenceId) {
const nextSequence = useNextSequence(sequenceId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
return useCallback(() => {
if (nextSequence !== null) {
const nextUnitId = nextSequence.unitIds[0];
history.push(`/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
}
}, [courseStatus, sequenceStatus, sequenceId]);
}
function usePreviousSequenceHandler(courseId, sequenceId) {
const previousSequence = usePreviousSequence(sequenceId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
return useCallback(() => {
if (previousSequence !== null) {
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
history.push(`/course/${courseId}/${previousSequence.id}/${previousUnitId}`);
}
}, [courseStatus, sequenceStatus, sequenceId]);
}
function useExamRedirect(sequenceId) {
const sequence = useModel('sequences', sequenceId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
useEffect(() => {
if (sequenceStatus === 'loaded' && sequence.isTimeLimited) {
const checkExamRedirect = memoize((sequenceStatus, sequence) => {
if (sequenceStatus === SEQUENCE_LOADED) {
if (sequence.isTimeLimited && sequence.lmsWebUrl !== undefined) {
global.location.assign(sequence.lmsWebUrl);
}
}, [sequenceStatus, sequence]);
}
}
});
function useContentRedirect(courseStatus, sequenceStatus) {
const match = useRouteMatch();
const { courseId, sequenceId, unitId } = match.params;
const sequence = useModel('sequences', sequenceId);
const firstSequenceId = useSelector(firstSequenceIdSelector);
useEffect(() => {
if (courseStatus === 'loaded' && !sequenceId) {
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
if (courseStatus === COURSE_LOADED && !sequenceId) {
// Note that getResumeBlock is just an API call, not a redux thunk.
getResumeBlock(courseId).then((data) => {
// This is a replace because we don't want this change saved in the browser's history.
history.replace(`/course/${courseId}/${firstSequenceId}`);
}
}, [courseStatus, sequenceId]);
useEffect(() => {
if (sequenceStatus === 'loaded' && sequenceId && !unitId) {
// The position may be null, in which case we'll just assume 0.
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
const unitIndex = sequence.position || 0;
const nextUnitId = sequence.unitIds[unitIndex];
// This is a replace because we don't want this change saved in the browser's history.
history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
if (data.sectionId && data.unitId) {
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
} else if (firstSequenceId) {
history.replace(`/course/${courseId}/${firstSequenceId}`);
}
}
}, [sequenceStatus, sequenceId, unitId]);
}
});
}
});
function useSavedSequencePosition(courseId, sequenceId, unitId) {
const dispatch = useDispatch();
const sequence = useModel('sequences', sequenceId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
useEffect(() => {
if (sequenceStatus === 'loaded' && sequence.savePosition) {
const checkContentRedirect = memoize((courseId, sequenceStatus, sequenceId, sequence, unitId) => {
if (sequenceStatus === SEQUENCE_LOADED && sequenceId && !unitId) {
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
// This is a replace because we don't want this change saved in the browser's history.
history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
}
}
});
class CoursewareContainer extends Component {
checkSaveSequencePosition = memoize((unitId) => {
const {
courseId,
sequenceId,
sequenceStatus,
sequence,
} = this.props;
if (sequenceStatus === SEQUENCE_LOADED && sequence.saveUnitPosition && unitId) {
const activeUnitIndex = sequence.unitIds.indexOf(unitId);
dispatch(saveSequencePosition(courseId, sequenceId, activeUnitIndex));
this.props.saveSequencePosition(courseId, sequenceId, activeUnitIndex);
}
}, [unitId]);
}
});
export default function CoursewareContainer() {
const { params } = useRouteMatch();
const {
courseId: routeCourseUsageKey,
sequenceId: routeSequenceId,
unitId: routeUnitId,
} = params;
const dispatch = useDispatch();
checkFetchCourse = memoize((courseId) => {
this.props.fetchCourse(courseId);
});
useEffect(() => {
dispatch(fetchCourse(routeCourseUsageKey));
}, [routeCourseUsageKey]);
useEffect(() => {
if (routeSequenceId) {
dispatch(fetchSequence(routeSequenceId));
checkFetchSequence = memoize((sequenceId) => {
if (sequenceId) {
this.props.fetchSequence(sequenceId);
}
}, [routeSequenceId]);
});
// The courseId and sequenceId in the store are the entities we currently have loaded.
// We get these two IDs from the store because until fetchCourse and fetchSequence below have
// finished their work, the IDs in the URL are not representative of what we should actually show.
// This is important particularly when switching sequences. Until a new sequence is fully loaded,
// there's information that we don't have yet - if we use the URL's sequence ID to tell the app
// which sequence is loaded, we'll instantly try to pull it out of the store and use it, before
// the sequenceStatus flag has even switched back to "loading", which will put our app into an
// invalid state.
const {
courseId,
sequenceId,
courseStatus,
sequenceStatus,
} = useSelector(state => state.courseware);
const nextSequenceHandler = useNextSequenceHandler(courseId, sequenceId);
const previousSequenceHandler = usePreviousSequenceHandler(courseId, sequenceId);
const unitNavigationHandler = useUnitNavigationHandler(courseId, sequenceId, routeUnitId);
useContentRedirect(courseStatus, sequenceStatus);
useExamRedirect(sequenceId);
useSavedSequencePosition(courseId, sequenceId, routeUnitId);
if (courseStatus === 'denied') {
return <Redirect to={`/redirect/course-home/${courseId}`} />;
componentDidMount() {
const {
match: {
params: {
courseId: routeCourseId,
sequenceId: routeSequenceId,
},
},
} = this.props;
// Load data whenever the course or sequence ID changes.
this.checkFetchCourse(routeCourseId);
this.checkFetchSequence(routeSequenceId);
}
return (
<main className="flex-grow-1 d-flex flex-column">
<Course
componentDidUpdate() {
const {
courseId,
sequenceId,
courseStatus,
sequenceStatus,
sequence,
firstSequenceId,
match: {
params: {
courseId: routeCourseId,
sequenceId: routeSequenceId,
unitId: routeUnitId,
},
},
} = this.props;
// Load data whenever the course or sequence ID changes.
this.checkFetchCourse(routeCourseId);
this.checkFetchSequence(routeSequenceId);
// Redirect to the legacy experience for exams.
checkExamRedirect(sequenceStatus, sequence);
// Determine if we need to redirect because our URL is incomplete.
checkContentRedirect(courseId, sequenceStatus, sequenceId, sequence, routeUnitId);
// Determine if we can resume where we left off.
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId);
// Check if we should save our sequence position. Only do this when the route unit ID changes.
this.checkSaveSequencePosition(routeUnitId);
}
handleUnitNavigationClick = (nextUnitId) => {
const {
courseId, sequenceId, unitId,
} = this.props;
this.props.checkBlockCompletion(courseId, sequenceId, unitId);
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
}
handleNextSequenceClick = () => {
const {
course,
courseId,
nextSequence,
sequence,
sequenceId,
} = this.props;
if (nextSequence !== null) {
let nextUnitId = null;
if (nextSequence.unitIds.length > 0) {
[nextUnitId] = nextSequence.unitIds;
history.push(`/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
} else {
// Some sequences have no units. This will show a blank page with prev/next buttons.
history.push(`/course/${courseId}/${nextSequence.id}`);
}
const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
if (celebrateFirstSection && sequence.sectionId !== nextSequence.sectionId) {
handleNextSectionCelebration(sequenceId, nextSequence.id, nextUnitId);
}
}
}
handlePreviousSequenceClick = () => {
const { previousSequence, courseId } = this.props;
if (previousSequence !== null) {
if (previousSequence.unitIds.length > 0) {
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
history.push(`/course/${courseId}/${previousSequence.id}/${previousUnitId}`);
} else {
// Some sequences have no units. This will show a blank page with prev/next buttons.
history.push(`/course/${courseId}/${previousSequence.id}`);
}
}
}
renderDenied() {
const { courseId, course } = this.props;
let url = `/redirect/course-home/${courseId}`;
switch (course.canLoadCourseware.errorCode) {
case 'audit_expired':
url = `/redirect/dashboard?access_response_error=${course.canLoadCourseware.additionalContextUserMessage}`;
break;
case 'course_not_started':
// eslint-disable-next-line no-case-declarations
const startDate = (new Intl.DateTimeFormat(getLocale())).format(new Date(course.start));
url = `/redirect/dashboard?notlive=${startDate}`;
break;
case 'survey_required': // TODO: Redirect to the course survey
case 'unfulfilled_milestones':
url = '/redirect/dashboard';
break;
case 'authentication_required':
case 'enrollment_required':
default:
}
return (
<Redirect to={url} />
);
}
render() {
const {
courseStatus,
courseId,
sequenceId,
match: {
params: {
unitId: routeUnitId,
},
},
} = this.props;
if (courseStatus === COURSE_DENIED) {
return this.renderDenied();
}
return (
<TabPage
activeTabSlug="courseware"
courseId={courseId}
sequenceId={sequenceId}
unitId={routeUnitId}
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
unitNavigationHandler={unitNavigationHandler}
/>
</main>
);
courseStatus={courseStatus}
>
<Course
courseId={courseId}
sequenceId={sequenceId}
unitId={routeUnitId}
nextSequenceHandler={this.handleNextSequenceClick}
previousSequenceHandler={this.handlePreviousSequenceClick}
unitNavigationHandler={this.handleUnitNavigationClick}
/>
</TabPage>
);
}
}
const sequenceShape = PropTypes.shape({
id: PropTypes.string.isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
isTimeLimited: PropTypes.bool,
lmsWebUrl: PropTypes.string,
});
const courseShape = PropTypes.shape({
canLoadCourseware: PropTypes.shape({
errorCode: PropTypes.string,
additionalContextUserMessage: PropTypes.string,
}).isRequired,
});
CoursewareContainer.propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
@@ -188,4 +259,123 @@ CoursewareContainer.propTypes = {
unitId: PropTypes.string,
}).isRequired,
}).isRequired,
courseId: PropTypes.string,
sequenceId: PropTypes.string,
firstSequenceId: PropTypes.string,
unitId: PropTypes.string,
courseStatus: PropTypes.oneOf([COURSE_LOADED, COURSE_LOADING, COURSE_FAILED, COURSE_DENIED]).isRequired,
sequenceStatus: PropTypes.oneOf([SEQUENCE_LOADED, SEQUENCE_LOADING, SEQUENCE_FAILED]).isRequired,
nextSequence: sequenceShape,
previousSequence: sequenceShape,
course: courseShape,
sequence: sequenceShape,
saveSequencePosition: PropTypes.func.isRequired,
checkBlockCompletion: PropTypes.func.isRequired,
fetchCourse: PropTypes.func.isRequired,
fetchSequence: PropTypes.func.isRequired,
};
CoursewareContainer.defaultProps = {
courseId: null,
sequenceId: null,
firstSequenceId: null,
unitId: null,
nextSequence: null,
previousSequence: null,
course: null,
sequence: null,
};
const currentSequenceSelector = createSelector(
(state) => state.models.sequences || {},
(state) => state.courseware.sequenceId,
(sequencesById, sequenceId) => (sequencesById[sequenceId] ? sequencesById[sequenceId] : null),
);
const sequenceIdsSelector = createSelector(
(state) => state.activeCourse.courseStatus,
activeCourseSelector,
(state) => state.models.sections,
(courseStatus, course, sectionsById) => {
if (courseStatus !== COURSE_LOADED) {
return [];
}
const { sectionIds = [] } = course;
return sectionIds.flatMap(sectionId => sectionsById[sectionId].sequenceIds);
},
);
const previousSequenceSelector = createSelector(
sequenceIdsSelector,
(state) => state.models.sequences || {},
(state) => state.courseware.sequenceId,
(sequenceIds, sequencesById, sequenceId) => {
if (!sequenceId || sequenceIds.length === 0) {
return null;
}
const sequenceIndex = sequenceIds.indexOf(sequenceId);
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
return previousSequenceId !== null ? sequencesById[previousSequenceId] : null;
},
);
const nextSequenceSelector = createSelector(
sequenceIdsSelector,
(state) => state.models.sequences || {},
(state) => state.courseware.sequenceId,
(sequenceIds, sequencesById, sequenceId) => {
if (!sequenceId || sequenceIds.length === 0) {
return null;
}
const sequenceIndex = sequenceIds.indexOf(sequenceId);
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
return nextSequenceId !== null ? sequencesById[nextSequenceId] : null;
},
);
const firstSequenceIdSelector = createSelector(
(state) => state.activeCourse.courseStatus,
activeCourseSelector,
(state) => state.models.sections || {},
(courseStatus, course, sectionsById) => {
if (courseStatus !== COURSE_LOADED) {
return null;
}
const { sectionIds = [] } = course;
if (sectionIds.length === 0) {
return null;
}
return sectionsById[sectionIds[0]].sequenceIds[0];
},
);
const mapStateToProps = (state) => {
const {
sequenceId, sequenceStatus, unitId,
} = state.courseware;
const {
courseId, courseStatus,
} = state.activeCourse;
return {
courseId,
sequenceId,
unitId,
courseStatus,
sequenceStatus,
course: activeCourseSelector(state),
sequence: currentSequenceSelector(state),
previousSequence: previousSequenceSelector(state),
nextSequence: nextSequenceSelector(state),
firstSequenceId: firstSequenceIdSelector(state),
};
};
export default connect(mapStateToProps, {
checkBlockCompletion,
saveSequencePosition,
fetchCourse,
fetchSequence,
})(CoursewareContainer);

View File

@@ -0,0 +1,378 @@
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 '@testing-library/jest-dom/extend-expect';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Route, Switch } from 'react-router';
import { Factory } from 'rosie';
import MockAdapter from 'axios-mock-adapter';
import { UserMessagesProvider } from '../generic/user-messages';
import tabMessages from '../tab-page/messages';
import initializeMockApp from '../setupTest';
import CoursewareContainer from './CoursewareContainer';
import buildSimpleCourseBlocks from './data/__factories__/courseBlocks.factory';
import initializeStore from '../store';
// NOTE: Because the unit creates an iframe, we choose to mock it out as its rendering isn't
// pertinent to this test. Instead, we render a simple div that displays the properties we expect
// to have been passed into the component. Separate tests can handle unit rendering, but this
// proves that the component is rendered and receives the correct props. We probably COULD render
// Unit.jsx and its iframe in this test, but it's already complex enough.
function MockUnit({ courseId, id }) { // eslint-disable-line react/prop-types
return (
<div className="fake-unit">Unit Contents {courseId} {id}</div>
);
}
jest.mock(
'./course/sequence/Unit',
() => MockUnit,
);
initializeMockApp();
describe('CoursewareContainer', () => {
let store;
let component;
let axiosMock;
beforeEach(() => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Switch>
<Route
path={[
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
]}
component={CoursewareContainer}
/>
</Switch>
</UserMessagesProvider>
</AppProvider>
);
});
it('should initialize to show a spinner', () => {
history.push('/course/abc123');
render(component);
const spinner = screen.getByRole('status');
expect(spinner.firstChild).toContainHTML(
`<span class="sr-only">${tabMessages.loading.defaultMessage}</span>`,
);
});
describe('when receiving successful course data', () => {
let courseId;
let courseMetadata;
let courseBlocks;
let sequenceMetadata;
let sequenceBlock;
let unitBlocks;
function assertLoadedHeader(container) {
const courseHeader = container.querySelector('.course-header');
// Ensure the course number and org appear - this proves we loaded course metadata properly.
expect(courseHeader).toHaveTextContent(courseMetadata.number);
expect(courseHeader).toHaveTextContent(courseMetadata.org);
// Ensure the course title is showing up in the header. This means we loaded course blocks properly.
expect(courseHeader.querySelector('.course-title')).toHaveTextContent(courseMetadata.name);
}
function assertSequenceNavigation(container) {
// Ensure we had appropriate sequence navigation buttons. We should only have one unit.
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
expect(sequenceNavButtons).toHaveLength(5);
expect(sequenceNavButtons[0]).toHaveTextContent('Previous');
// Prove this button is rendering an SVG tasks icon, meaning it's a unit/vertical.
expect(sequenceNavButtons[1].querySelector('svg')).toHaveClass('fa-tasks');
expect(sequenceNavButtons[4]).toHaveTextContent('Next');
}
function setupMockRequests() {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`).reply(200, courseMetadata);
axiosMock.onGet(new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`)).reply(200, courseBlocks);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceBlock.id}`).reply(200, sequenceMetadata);
}
beforeEach(async () => {
// On page load, SequenceContext attempts to scroll to the top of the page.
global.scrollTo = jest.fn();
courseMetadata = Factory.build('courseMetadata');
courseId = courseMetadata.id;
const customUnitBlocks = [
Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
),
Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
),
Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
),
];
const result = buildSimpleCourseBlocks(courseId, courseMetadata.name, { unitBlocks: customUnitBlocks });
courseBlocks = result.courseBlocks;
unitBlocks = result.unitBlocks;
// eslint-disable-next-line prefer-destructuring
sequenceBlock = result.sequenceBlock[0];
sequenceMetadata = Factory.build(
'sequenceMetadata',
{},
{ courseId, unitBlocks, sequenceBlock },
);
setupMockRequests();
});
describe('when the URL only contains a course ID', () => {
it('should use the resume block repsonse to pick a unit if it contains one', async () => {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {
sectionId: sequenceBlock.id,
unitId: unitBlocks[1].id,
});
history.push(`/course/${courseId}`);
const { container } = render(component);
// 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'));
assertLoadedHeader(container);
assertSequenceNavigation(container);
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[1].id);
});
it('should use the first sequence ID and activeUnitIndex if the resume block response is empty', async () => {
// OVERRIDE SEQUENCE METADATA:
// set the position to the third unit so we can prove activeUnitIndex is working
sequenceMetadata = Factory.build(
'sequenceMetadata',
{ position: 3 }, // position index is 1-based and is converted to 0-based for activeUnitIndex
{ courseId, unitBlocks, sequenceBlock },
);
// Re-call the mock setup now that sequenceMetadata is different.
setupMockRequests();
// Note how there is no sectionId/unitId returned in this mock response!
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
history.push(`/course/${courseId}`);
const { container } = render(component);
// 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'));
assertLoadedHeader(container);
assertSequenceNavigation(container);
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
});
});
describe('when the URL contains a course ID and sequence ID', () => {
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}`);
const { container } = render(component);
// 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'));
assertLoadedHeader(container);
assertSequenceNavigation(container);
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[0].id);
});
it('should use activeUnitIndex to pick a unit from the sequence', async () => {
// OVERRIDE SEQUENCE METADATA:
sequenceMetadata = Factory.build(
'sequenceMetadata',
{ position: 3 }, // position index is 1-based and is converted to 0-based for activeUnitIndex
{ courseId, unitBlocks, sequenceBlock },
);
// Re-call the mock setup now that sequenceMetadata is different.
setupMockRequests();
history.push(`/course/${courseId}/${sequenceBlock.id}`);
const { container } = render(component);
// 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'));
assertLoadedHeader(container);
assertSequenceNavigation(container);
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
});
});
describe('when the URL contains a course, sequence, and unit ID', () => {
it('should load the specified unit', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
const { container } = render(component);
// 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'));
assertLoadedHeader(container);
assertSequenceNavigation(container);
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
});
});
describe('when the current sequence is an exam', () => {
const { location } = window;
beforeEach(() => {
delete window.location;
window.location = {
assign: jest.fn(),
};
});
afterEach(() => {
window.location = location;
});
it('should redirect to the sequence lmsWebUrl', async () => {
// OVERRIDE SEQUENCE METADATA:
sequenceMetadata = Factory.build(
'sequenceMetadata',
{ is_time_limited: true }, // position index is 1-based and is converted to 0-based for activeUnitIndex
{ courseId, unitBlocks, sequenceBlock },
);
// Re-call the mock setup now that sequenceMetadata is different.
setupMockRequests();
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
render(component);
// 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'));
expect(global.location.assign).toHaveBeenCalledWith(sequenceBlock.lms_web_url);
});
});
});
describe('when receiving a can_load_courseware error_code', () => {
let courseMetadata;
function setupWithDeniedStatus(errorCode) {
courseMetadata = Factory.build('courseMetadata', {
can_load_courseware: {
has_access: false,
error_code: errorCode,
additional_context_user_message: 'uhoh oh no', // only used by audit_expired
},
});
const courseId = courseMetadata.id;
const { courseBlocks, unitBlocks, sequenceBlock } = buildSimpleCourseBlocks(courseId, courseMetadata.name);
const sequenceMetadata = Factory.build(
'sequenceMetadata',
{},
{ courseId, unitBlocks, sequenceBlock },
);
const forbiddenCourseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceBlock.id}`;
axiosMock.onGet(forbiddenCourseUrl).reply(200, courseMetadata);
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
axiosMock.onGet(sequenceMetadataUrl).reply(200, sequenceMetadata);
history.push(`/course/${courseId}`);
}
it('should go to course home for an enrollment_required error code', async () => {
setupWithDeniedStatus('enrollment_required');
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
});
it('should go to course home for an authentication_required error code', async () => {
setupWithDeniedStatus('authentication_required');
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
});
it('should go to dashboard for an unfulfilled_milestones error code', async () => {
setupWithDeniedStatus('unfulfilled_milestones');
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
expect(global.location.href).toEqual('http://localhost/redirect/dashboard');
});
it('should go to the dashboard with an attached access_response_error for an audit_expired error code', async () => {
setupWithDeniedStatus('audit_expired');
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
expect(global.location.href).toEqual('http://localhost/redirect/dashboard?access_response_error=uhoh%20oh%20no');
});
it('should go to the dashboard with a notlive start date for a course_not_started error code', async () => {
setupWithDeniedStatus('course_not_started');
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
const startDate = '2/5/2013'; // This date is based on our courseMetadata factory's sample data.
expect(global.location.href).toEqual(`http://localhost/redirect/dashboard?notlive=${startDate}`);
});
});
});

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { Switch, Route, useRouteMatch } from 'react-router';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import PageLoading from './PageLoading';
import PageLoading from '../generic/PageLoading';
export default () => {
const { path } = useRouteMatch();
@@ -24,6 +24,12 @@ export default () => {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/course/`);
}}
/>
<Route
path={`${path}/dashboard`}
render={({ location }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`);
}}
/>
</Switch>
</div>
);

View File

@@ -1,30 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { useDispatch } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { useSelector } from 'react-redux';
import AlertList from '../../user-messages/AlertList';
import { useLogistrationAlert } from '../../logistration-alert';
import { useEnrollmentAlert } from '../../enrollment-alert';
import PageLoading from '../../PageLoading';
import { AlertList } from '../../generic/user-messages';
import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
import useOfferAlert from '../../alerts/offer-alert';
import InstructorToolbar from './InstructorToolbar';
import Sequence from './sequence';
import { CelebrationModal, shouldCelebrateOnSectionLoad } from './celebration';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import { Header, CourseTabsNavigation } from '../../course-header';
import CourseSock from './course-sock';
import Calculator from './calculator';
import messages from './messages';
import { useModel } from '../../model-store';
// Note that we import from the component files themselves in the enrollment-alert package.
// This is because Reacy.lazy() requires that we import() from a file with a Component as it's
// default export.
// See React.lazy docs here: https://reactjs.org/docs/code-splitting.html#reactlazy
const EnrollmentAlert = React.lazy(() => import('../../enrollment-alert/EnrollmentAlert'));
const StaffEnrollmentAlert = React.lazy(() => import('../../enrollment-alert/StaffEnrollmentAlert'));
const LogistrationAlert = React.lazy(() => import('../../logistration-alert'));
import ContentTools from './content-tools';
import { useModel } from '../../generic/model-store';
function Course({
courseId,
@@ -33,80 +23,69 @@ function Course({
nextSequenceHandler,
previousSequenceHandler,
unitNavigationHandler,
intl,
}) {
const course = useModel('courses', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sequence ? sequence.sectionId : null);
useLogistrationAlert();
useEnrollmentAlert(courseId);
const pageTitleBreadCrumbs = [
sequence,
section,
course,
].filter(element => element != null).map(element => element.title);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const {
canShowUpgradeSock,
celebrations,
courseExpiredMessage,
offerHtml,
verifiedMode,
} = course;
if (courseStatus === 'loading') {
return (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
/>
);
}
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
const offerAlert = useOfferAlert(offerHtml, 'course');
const accessExpirationAlert = useAccessExpirationAlert(courseExpiredMessage, 'course');
if (courseStatus === 'loaded') {
const {
org, number, title, isStaff, tabs, verifiedMode, showCalculator,
} = course;
return (
<>
<Header
courseOrg={org}
courseNumber={number}
courseTitle={title}
/>
{isStaff && (
<InstructorToolbar
unitId={unitId}
/>
)}
<CourseTabsNavigation tabs={tabs} activeTabSlug="courseware" />
<div className="container-fluid">
<AlertList
className="my-3"
topic="course"
customAlerts={{
clientEnrollmentAlert: EnrollmentAlert,
clientStaffEnrollmentAlert: StaffEnrollmentAlert,
clientLogistrationAlert: LogistrationAlert,
}}
/>
<CourseBreadcrumbs
courseId={courseId}
sectionId={section ? section.id : null}
sequenceId={sequenceId}
/>
<AlertList topic="sequence" />
</div>
<div className="flex-grow-1 d-flex flex-column">
<Sequence
unitId={unitId}
sequenceId={sequenceId}
courseId={courseId}
unitNavigationHandler={unitNavigationHandler}
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
/>
{verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
{showCalculator && <Calculator />}
</div>
</>
);
}
const dispatch = useDispatch();
const celebrateFirstSection = celebrations && celebrations.firstSection;
const celebrationOpen = shouldCelebrateOnSectionLoad(courseId, sequenceId, unitId, celebrateFirstSection, dispatch);
// courseStatus 'failed' and any other unexpected course status.
return (
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
{intl.formatMessage(messages['learn.course.load.failure'])}
</p>
<>
<Helmet>
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
</Helmet>
<AlertList
className="my-3"
topic="course"
customAlerts={{
...accessExpirationAlert,
...offerAlert,
}}
/>
<CourseBreadcrumbs
courseId={courseId}
sectionId={section ? section.id : null}
sequenceId={sequenceId}
/>
<AlertList topic="sequence" />
<Sequence
unitId={unitId}
sequenceId={sequenceId}
courseId={courseId}
unitNavigationHandler={unitNavigationHandler}
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
/>
{celebrationOpen && (
<CelebrationModal
courseId={courseId}
open
/>
)}
{canShowUpgradeSock && verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
<ContentTools course={course} />
</>
);
}
@@ -117,7 +96,6 @@ Course.propTypes = {
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
unitNavigationHandler: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
Course.defaultProps = {
@@ -126,4 +104,4 @@ Course.defaultProps = {
unitId: null,
};
export default injectIntl(Course);
export default Course;

View File

@@ -5,7 +5,9 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHome } from '@fortawesome/free-solid-svg-icons';
import { useSelector } from 'react-redux';
import { useModel } from '../../model-store';
import { useModel } from '../../generic/model-store';
import { COURSE_LOADED } from '../../active-course';
import { SEQUENCE_LOADED } from '../data';
function CourseBreadcrumb({
url, children, withSeparator, ...attrs
@@ -40,11 +42,11 @@ export default function CourseBreadcrumbs({
const course = useModel('courses', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sectionId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const courseStatus = useSelector(state => state.activeCourse.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const links = useMemo(() => {
if (courseStatus === 'loaded' && sequenceStatus === 'loaded') {
if (courseStatus === COURSE_LOADED && sequenceStatus === SEQUENCE_LOADED) {
return [section, sequence].filter(node => !!node).map((node) => ({
id: node.id,
label: node.title,

View File

@@ -1,60 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Collapsible } from '@edx/paragon';
function InstructorToolbar(props) {
// TODO: Only render this toolbar if the user is course staff
if (!props.activeUnitLmsWebUrl) {
return null;
}
return (
<div className="bg-primary text-light">
<div className="container-fluid py-3 d-md-flex justify-content-end align-items-center">
<div className="flex-grow-1">
<Collapsible.Advanced className="mr-5 mb-md-0">
You are currently previewing the new learning sequence experience.
<Collapsible.Trigger className="d-inline-block ml-2" style={{ cursor: 'pointer' }}>
<Collapsible.Visible whenClosed>
<span style={{ borderBottom: 'solid 1px white' }}>More info</span> &rarr;
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body>
This preview is to allow for early content testing, especially for custom content blocks, with the goal of ensuring it renders as expected in the next experience. You can learn more through the following <a className="text-white" style={{ textDecoration: 'underline' }} href="https://partners.edx.org/announcements/author-preview-learning-sequence-experience-update" target="blank" rel="noopener">Partner Portal post</a>. Please report any issues or provide <a className="text-white" style={{ textDecoration: 'underline' }} target="blank" rel="noopener" href="https://forms.gle/R6jMYJNTCj1vgC1D6">feedback using the linked form</a>.
<Collapsible.Trigger className="d-inline-block ml-2" style={{ cursor: 'pointer' }}>
<Collapsible.Visible whenOpen>
<span style={{ borderBottom: 'solid 1px white' }}>Close</span> &times;
</Collapsible.Visible>
</Collapsible.Trigger>
</Collapsible.Body>
</Collapsible.Advanced>
</div>
<div className="flex-shrink-0">
<a className="btn d-block btn-outline-light" href={props.activeUnitLmsWebUrl}>View unit in the existing experience</a>
</div>
</div>
</div>
);
}
InstructorToolbar.propTypes = {
activeUnitLmsWebUrl: PropTypes.string,
};
InstructorToolbar.defaultProps = {
activeUnitLmsWebUrl: undefined,
};
const mapStateToProps = (state, props) => {
if (!props.unitId) {
return {};
}
const activeUnit = state.models.units[props.unitId];
return {
activeUnitLmsWebUrl: activeUnit ? activeUnit.lmsWebUrl : undefined,
};
};
export default connect(mapStateToProps)(InstructorToolbar);

View File

@@ -0,0 +1,87 @@
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import executeThunk from '../../../../utils';
import initializeMockApp from '../../../../setupTest';
import initializeStore from '../../../../store';
import {
addBookmark,
removeBookmark,
BOOKMARK_FAILED,
BOOKMARK_LOADED,
} from './thunks';
const { loggingService } = initializeMockApp();
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Data layer integration tests', () => {
const unitId = 'unitId';
let store;
beforeEach(() => {
axiosMock.reset();
loggingService.logError.mockReset();
store = initializeStore();
});
describe('Test addBookmark', () => {
const createBookmarkURL = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
it('Should fail to create bookmark in case of error', async () => {
axiosMock.onPost(createBookmarkURL).networkError();
await executeThunk(addBookmark(unitId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(axiosMock.history.post[0].url).toEqual(createBookmarkURL);
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
bookmarked: false,
bookmarkedUpdateState: BOOKMARK_FAILED,
}));
});
it('Should create bookmark and update model state', async () => {
axiosMock.onPost(createBookmarkURL).reply(201);
await executeThunk(addBookmark(unitId), store.dispatch);
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
bookmarked: true,
bookmarkedUpdateState: BOOKMARK_LOADED,
}));
});
});
describe('Test removeBookmark', () => {
const deleteBookmarkURL = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/${getAuthenticatedUser().username},${unitId}/`;
it('Should fail to remove bookmark in case of error', async () => {
axiosMock.onDelete(deleteBookmarkURL).networkError();
await executeThunk(removeBookmark(unitId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(axiosMock.history.delete[0].url).toEqual(deleteBookmarkURL);
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
bookmarked: true,
bookmarkedUpdateState: BOOKMARK_FAILED,
}));
});
it('Should delete bookmark and update model state', async () => {
axiosMock.onDelete(deleteBookmarkURL).reply(201);
await executeThunk(removeBookmark(unitId), store.dispatch);
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
bookmarked: false,
bookmarkedUpdateState: BOOKMARK_LOADED,
}));
});
});
});

View File

@@ -1,10 +1,13 @@
import { logError } from '@edx/frontend-platform/logging';
import {
createBookmark,
deleteBookmark,
} from './api';
import { updateModel } from '../../../../model-store';
import { updateModel } from '../../../../generic/model-store';
export const BOOKMARK_LOADING = 'loading';
export const BOOKMARK_LOADED = 'loaded';
export const BOOKMARK_FAILED = 'failed';
export function addBookmark(unitId) {
return async (dispatch) => {
@@ -14,7 +17,7 @@ export function addBookmark(unitId) {
model: {
id: unitId,
bookmarked: true,
bookmarkedUpdateState: 'loading',
bookmarkedUpdateState: BOOKMARK_LOADING,
},
}));
@@ -25,7 +28,7 @@ export function addBookmark(unitId) {
model: {
id: unitId,
bookmarked: true,
bookmarkedUpdateState: 'loaded',
bookmarkedUpdateState: BOOKMARK_LOADED,
},
}));
} catch (error) {
@@ -35,7 +38,7 @@ export function addBookmark(unitId) {
model: {
id: unitId,
bookmarked: false,
bookmarkedUpdateState: 'failed',
bookmarkedUpdateState: BOOKMARK_FAILED,
},
}));
}
@@ -50,7 +53,7 @@ export function removeBookmark(unitId) {
model: {
id: unitId,
bookmarked: false,
bookmarkedUpdateState: 'loading',
bookmarkedUpdateState: BOOKMARK_LOADING,
},
}));
try {
@@ -60,7 +63,7 @@ export function removeBookmark(unitId) {
model: {
id: unitId,
bookmarked: false,
bookmarkedUpdateState: 'loaded',
bookmarkedUpdateState: BOOKMARK_LOADED,
},
}));
} catch (error) {
@@ -70,7 +73,7 @@ export function removeBookmark(unitId) {
model: {
id: unitId,
bookmarked: true,
bookmarkedUpdateState: 'failed',
bookmarkedUpdateState: BOOKMARK_FAILED,
},
}));
}

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