Compare commits

..

69 Commits

Author SHA1 Message Date
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
David Joy
a10e6c2826 Switching the MFE to use the new permissions fields (#43)
- “userHasAccess” becomes “canLoadCourseware”, and is loaded from “can_load_courseware”
- “isStaff” is now loaded from “is_staff” instead of “user_has_staff_access”
2020-04-06 15:32:50 -04:00
David Joy
b4fbd1cf83 fix: “current.” was left over from when the implementation used refs (#42) 2020-04-02 15:58:58 -04:00
Adam Butterworth
37610ab181 Improve access control behavior (#39)
Fixes TNL-7175: Redirect to course home if a user is not unenrolled and the course is private.

- Require authentication to use the app while course blocks api requires it
- Gracefully handle course blocks api request failures allowing app to proceed to it redirection logic

Notable changes:

- selectors related to sequences are more resilient to missing models. In the case the course blocks api returns successfully but empty (in this case of enrolled but course not yet started).
- `fetchCourse` thunk handles failures for fetchCourseMeta and fetchCourseBlocks separately using `Promise.allSettled` instead of `Promise.all`
- `denied` is a new `courseStatus`
- Access denied redirect is done using a component at a new route `redirect/course-home/:courseId`

Now handles cases

- User is unauthenticated > redirect to login
- User is authenticated but not enrolled > redirects to lms course home
- When an enrolled user attempts to access courseware before the course start date they will load the sequence (but unable to load the vertical block). This behavior should be fixed in an update to edx-platform
2020-04-02 15:12:07 -04:00
David Joy
70428228a5 fix: Fix UserMessagesProvider state references (#38)
See details in code, but this causes UserMessagesProvider to always use the most “recent” version of its messages and nextId state when its callbacks are called.
2020-04-01 16:07:58 -04:00
David Joy
1dc069dbbf Adding a separate StaffEnrollmentAlert (#41)
This is a separate component because we have no mechanism for passing context/state into these alerts right now, and I’m not sure it’s worth building.  Easier to just use different codes for different situations.
2020-04-01 15:59:05 -04:00
David Joy
9b72380dea Bumping paragon and fixing an i18n build issue (#40) 2020-04-01 15:42:10 -04:00
David Joy
d59875c45d Only redirect if the user has no access and isn’t staff (#37)
TNL-7129

This adds a third clause to our useAccessDeniedRedirect hook, which makes sure the user doesn’t have staff access (instead of normal, enrolled access) prior to redirecting.

As an aside, this redirection approach - irrespective of this PR - is not great.  The UI mostly renders prior to this redirect happening.  It would be better of this hook returned something that would help prevent the UI from rendering while the redirect is in progress.  As it stands, a redirected user will see a flash of the page content prior to being booted.  Not wonderful.
2020-04-01 10:18:54 -04:00
David Joy
834b922aff Create 0005-components-own-their-loading-state.md 2020-04-01 10:13:59 -04:00
Dave St.Germain
a1776f4366 Added calculator component (#33)
TNL-7138
2020-03-30 11:59:31 -04:00
Adam Butterworth
f8ff2e7860 Add sequence-container (#36) 2020-03-25 15:26:51 -04:00
David Joy
a923f3d8e7 fix: use history.push to preserve history for user navigation (#35)
We continue to use history.replace for building the MFE URL, which we don’t want saved in history.
2020-03-24 11:09:45 -04:00
David Joy
8f4ff79351 Rename courseUsageKey to courseId 2020-03-23 17:26:33 -04:00
David Joy
c8be4c401f Allow loading with no unit, and of sequences with no unitIds. (#34)
This requires some defensive programming here and there to let things load in a minimal state.
2020-03-23 16:40:50 -04:00
David Joy
781508dd03 fix: Sometimes a course won’t have units.
If a sequence has no unitIds, bail on changing the URL.
2020-03-23 14:15:25 -04:00
David Joy
57ca2948eb fix: unit object not always ready when course loaded
If we don’t yet have the course blocks API loaded when the course metadata finishes, `unit` will be undefined still, and `unit.id` will fail.
2020-03-23 11:59:34 -04:00
David Joy
9cbb765f8a Extensive refactor of application data management. (#32)
* Extensive refactor of application data management.

- “course-blocks” and “course-meta” are replaced with “courseware” module.  This obscures the difference between the two from the application itself.
- a generic “model-store” module is used to store all course, section, sequence, and unit data in a normalized way, agnostic to the metadata vs. blocks APIs.
- SequenceContainer has been removed, and it’s work is just done in CourseContainer instead.
- UI components are - in general - more responsible for deciding their own behavior during data loading.  If they want to show a spinner or nothing, it’s up to their discretion.
- The API layer is responsible for normalizing data into a form the app will want to use, prior to putting it into the model store.

* Organizing into some more sub-modules.

- Bookmarks becomes it’s own module.
- SequenceNavigation becomes another one.

* More modularization of data directories.

- Moving model-store up to the top.
- Moving fetchCourse and fetchSequence up to the top-level data directory, since they’re used by both courseware and outline.
- Moving getBlockCompletion and updateSequencePosition into the courseware/data directory, since they pertain to that page.

* Normalizing on using the word “title”

* Using history.replace instead of history.push

This fixes TNL-7125

* Allowing sub-components to use hooks and redux

This reduces the amount of data we need to pass around, and lets us move some complexity to more natural modules.

* Fixing bug where enrollment alert is shown for undefined isEnrolled

The enrollment alert would inadvertently be shown if a user navigated from the outline to the course.  This was because it interpreted an undefined “isEnrolled” flag as false.  Instead, we should wait for the isEnrolled flag to be explicitly true or false.

* Organizing modules.

- Renaming “outline” to “course-home”.
- Moving sequence and sequence-navigation modules under the course module.

* Some final application organization and ADR write-ups.

* Final refactoring

- Favoring passing data by ID and looking it up in the store with useModel.
- Moving headers into course-header directory.

* Updating ADRs.  Splitting model-store information out into its own ADR.
2020-03-23 11:31:09 -04:00
Adam Butterworth
720594a7cf Update index.scss
Fix media query
2020-03-16 14:29:23 -04:00
Adam Butterworth
94d11bc7c2 fix header on desktop (#31) 2020-03-16 14:21:28 -04:00
Adam Butterworth
fb83d881f6 Make header mobile responsive (#30)
TNL-7072
2020-03-13 16:46:35 -04:00
Adam Butterworth
da4711581a Responsive instructor toolbar and progressive disclosure (#29)
TNL-7072
2020-03-13 14:55:48 -04:00
Adam Butterworth
a4c978a303 Mobile responsive sequence navigation (#28)
[TNL-7072] When a sequence navigation would overflow, convert it to a dropdown.
2020-03-13 12:57:08 -04:00
Adam Butterworth
24ca1aa730 Improve sequence padding and containers on mobile (#27)
TNL-7072.
- Refactors some of the css container/content class naming
- Moved UnitNavigation out of the Sequence and into its own component.
- Fixes an issue with course tabs where multi-word titles would wrap text.
2020-03-11 11:43:17 -04:00
Adam Butterworth
a0839f0a63 Mobile layout for breadcrumbs and tabs (#26)
TNL-7072 mobile layout updates. Breadcrumbs truncate section and subsection titles with ellipsis. Tabs that would overflow are tucked under a "more" dropdown.
2020-03-11 09:37:54 -04:00
David Joy
d145c45a3b feat: Only showing instructor toolbar for course staff. (#25) 2020-03-10 09:33:36 -04:00
Adam Butterworth
fcddc2d639 Log api request errors to New Relic and display load error (#24)
TNL-7114 Logs any failed api request to New Relic. Show error in CourseContainer instead of a spinner when a course fails to load.
2020-03-09 12:43:02 -04:00
David Joy
6082ade9e0 Disabling next/previous buttons at course beginning and end (#23)
At the end, the “next” button at the bottom of the page is replaced with a friendly message.

This PR also alphabetizes some props for SequenceNavigation and Sequence, as I was adding two new ones - isFirst and isLast.
2020-03-06 16:38:47 -05:00
stvn
8358a2589e Merge PR #15 analytics/sequence-events
* Commits:
  Add logging events for sequence navigation
2020-03-06 16:16:47 -05:00
stvn
39a0e50745 Add logging events for sequence navigation
to mirror existing behavior in `edx-platform` [2][3][4].

This adds support for the following events:
- `edx.ui.lms.sequence.next_selected`
- `edx.ui.lms.sequence.previous_selected`
- `edx.ui.lms.sequence.tab_selected`

** Outstanding Events
- `edx.ui.lms.outline.*`
- `edx.ui.lms.link_clicked`

** References
- [1] https://openedx.atlassian.net/browse/TNL-7066
- [2] https://edx.readthedocs.io/projects/devdata/en/latest/internal_data_formats/tracking_logs/student_event_types.html#course-navigation-events
- [3] https://github.com/edx/edx-platform/blob/master/common/lib/xmodule/xmodule/js/src/sequence/display.js#L315-321
- [4] https://github.com/edx/edx-platform/blob/master/common/lib/xmodule/xmodule/js/src/sequence/display.js#L373-378
2020-03-06 13:00:16 -05:00
132 changed files with 4270 additions and 2334 deletions

2
.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,5 @@ ORDER_HISTORY_URL=null
REFRESH_ACCESS_TOKEN_ENDPOINT=null
SEGMENT_KEY=null
SITE_NAME=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,12 @@ 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'
STUDIO_BASE_URL='http://localhost:18010'
USER_INFO_COOKIE_NAME='edx-user-info'

View File

@@ -1,15 +1,18 @@
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'
STUDIO_BASE_URL='http://localhost:18010'
USER_INFO_COOKIE_NAME='edx-user-info'

0
LICENSE Executable file → Normal file
View File

0
Makefile Executable file → Normal file
View File

View File

@@ -1,21 +0,0 @@
# Courseware app structure
Currently we have hierarchical courses - they contain sections, subsections, units, and components.
We need data to power each level.
We've made decisions that we're going to re-fetch data at the subsection level under the assumption that
At any given level, you have the following structure:
Parent
Container
Child
Context
The container belongs to the parent module, and is an opportunity for the parent to decide to load more data necessary to load the Child. If the parent has what it needs, it may not use a Container. The Child has an props-only interface. It does _not_ use contexts or redux from the Parent. The child may decide to use a Context internally if that's convenient, but that's a decision independent of anything above the Child in the hierarchy.
This app uses a "model store" - a normalized representation of our API data. This data is kept in an Object with entity IDs as keys, and the entities as values. This allows the application to quickly look up data in the map using only a key. It also means that if the same entity is used in multiple places, there's only one actual representation of it in the client - anyone who wants to use it effectively maintains a reference to it via it's ID.
There are a few kinds of data in the model store. Information from the blocks API - courses, chapters, sequences, and units - are stored together by ID. Into this, we merge course, sequence, and unit metadata from the courses and sequence metadata APIs.

View File

@@ -0,0 +1,50 @@
# Courseware Page Decisions
## Courseware data loading
Today we have strictly hierarchical courses - a course contains sections, which contain sequences, which contain units, which contain components.
In creating the courseware pages of this app, we needed to choose how often we fetch data from the server. If we fetch it once and try to get the whole course, including all the data we need in its entire hierarchy, then the request will take 30+ seconds and be a horrible UX. If we try to fetch too granularly, we risk making hundreds of calls to the LMS, incuring both request overhead and common server-side processing that needs to occur for each of those requests.
Instead, we've chosen to load data via the following:
- The course blocks API (/api/courses/v2/blocks) for getting the overall structure of the course (limited data on the whole hierarchy)
- The course metadata API (/api/courseware/course) for detailed top-level data, such as dates, enrollment status, info for tabs across the top of the page, etc.
- The sequence metadata API (/api/courseware/sequence) for detailed information on a sequence, such as which unit to display, any banner messages, whether or not the sequence has a prerequisite, if it's an exam, etc.
- The xblock endpoint (http://localhost:18000/xblock/:block_id) which renders HTML for an xBlock by ID, used to render Unit contents. This HTML is loaded into the application via an iFrame.
These APIs aren't perfect for our usage, but they're getting the job done for now. They weren't built for our purposes and thus load more information than we strictly need, and aren't as performant as we'd like. Future milestones of the application may rely on new, more performant APIs (possibly BFFs)
## Unit iframing
We determined, as part of our project discovery, that in order to deliver value to users sooner, we would iframe in content of units. This allowed us to avoid rebuilding the UI for unit/component xblocks in the micro-frontend, which is a daunting task. It also allows existing custom xblocks to continue to work for now, as they wouldn't have to be re-written.
A future iteration of the project may go back and pull the unit rendering into the MFE.
## Strictly hierarchical courses
We've also made the assumption that courses are strictly hierarchical - a given section, sequence, or unit doesn't have multiple parents. This is important, as it allows us to navigate the tree in the client in a deterministic way. If we need to find out who the parent section of a sequence is, there's only one answer to that question.
## Determining which sequences and units to show
The courseware URL scheme:
`/course/:courseId(/:sequenceId(/:unitId))`
Sequence ID and unit ID are optional.
Today, if the URL only specifies the course ID, we need to pick a sequence to show. We do this by picking the first sequence of the course (as dictated by the course blocks API) and update the URL to match. _After_ the URL has been updated, the application will attempt to load that sequence.
Similarly, if the URL doesn't contain a unit ID, we use the `position` field of the sequence to determine which unit we want to display from that sequence. If the position isn't specified in the sequence, we choose the first unit of the sequence. After determining which unit to display, we update the URL to match. After the URL is updated, the application will attempt to load that unit via an iFrame.
## "Container" components vs. display components
This application makes use of a few "container" components at the top level - CoursewareContainer and CourseHomeContainer.
The point of these containers is to introduce a layer of abstraction between the UI representation of the pages and the way their data was loaded, as described above.
We don't want our Course.jsx component to be intimately aware - for example - that it's data is loaded via two separate APIs that are then merged together. That's not useful information - it just needs to know where it's data is and if it's loaded. Furthermore, this layer of abstraction lets us normalize field names between the various APIs to let our MFE code be more consistent and readable. This normalization is done in the src/data/api.js layer.
## Navigation
Course navigation in a hierarchical course happens primarily via the "sequence navigation". This component lets users navigate to the next and previous unit in the course, and also select specific units within the sequence directly. The next and previous buttons (SequenceNavigation and UnitNavigation) delegate decision making up the tree to CoursewareContainer. This is an intentional separation of concerns which should allow different CoursewareContainer-like components to make different decisions about what it means to go to the "next" or "previous" sequence. This is in support of future course types such as "pathway" courses and adaptive learning sequences. There is no actual code written for these course types, but it felt like a good separation of concerns.

View File

@@ -0,0 +1,7 @@
# Course Home Decisions
The course home page is not complete as of this writing.
It was added to the MFE as a proof of concept for the Engagement theme's Always Available squad, as they were intending to do some work in the legacy course home page in the LMS, and we wanted to understand whether it would be more easily done in this application.
It uses the same APIs as the courseware page, for the most part. This may not always be the case, but it is for now. Differing API shapes may be faster for both pages.

View File

@@ -0,0 +1,7 @@
## Model Store
Because we have a variety of models in this app (course, section, sequence, unit), we use a set of generic 'model store' reducers in redux to manage this data. Once loaded from the APIs, the data is put into the model store by type and by ID, which allows us to quickly access it in the application. Furthermore, any sub-trees of model children (like "items" in the sequence metadata API) are flattened out and stored by ID in the model-store, and their arrays replaced by arrays of IDs. This is a recommended way to store data in redux as documented here:
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)

View File

@@ -0,0 +1,17 @@
# Components Own Their Own Loading State
Currently, the majority of the components in the component tree for both Courseware and CourseHome own their own loading state. This means that they're _aware_ of the loading status (loading, loaded, failed) of the resources they depend on, and are expected to adjust their own rendering based on that state.
The alternative is for a given component's parent to be responsible for this logic. Under normal circumstances, if the parents were responsible, it would probably result in simpler code in general. A component could just take for granted that if it's being rendered, all it's data must be ready.
*We think that that approach (giving the parents responsibility) isn't appropriate for this application.*
We expect - in the longer term - that different courses/course staff may switch out component implementations. Use a different form of SequenceNavigation, for instance. Because of this, we didn't want parent components to be too aware of the nature of their children. The children are more self-contained this way, though we sacrifice some simplicity for it.
If, for instance, the Sequence component renders a skeleton of the SequenceNavigation, the look of that skeleton is going to be based on an understanding of how the SequenceNavigation renders itself. If the SequenceNavigation implementation is switched out, that loading code in the Sequence may be wrong/misleading to the user. If we leave the loading logic in the parent, we then have to branch it for all the types of SequenceNavigations that may exist - this violates the Open/Closed principle by forcing us to update our application when we try to make a new extension/implementation of a sub-component (assuming we have a plugin/extension/component replacement framework in place).
By moving the loading logic into the components themselves, the idea is to allow a given component to render as much of itself as it reasonably can - this may mean just a spinner, or it may mean a "skeleton" UI while the resources are loading. The parent doesn't need to be aware of the details.
## Under what circumstances would we reverse this decision?
If we find, in time, that we aren't seeing that "switching out component implementations" is a thing that's happening, then we can probably simplify the application code by giving parents the responsibility of deciding when to render their children, rather than keeping that responsibility with the children themselves.

View File

@@ -1,30 +0,0 @@
# Perf test courses
These courses have some large xblocks and small ones. One course has many sequences, the other has fewer.
## Big course: course-v1:MITx+CTL.SC0x+3T2016
- MFE URL: https://learning.edx.org/course/course-v1%3AMITx%2BCTL.SC0x%2B3T2016/0
- URL: https://courses.edx.org/courses/course-v1:MITx+CTL.SC0x+3T2016/course/
### Small xblock
- ID: block-v1:MITx+CTL.SC0x+3T2016+type@vertical+block@0586b59f1cf74e3c982f0b9070e7ad33
- URL: https://courses.edx.org/courses/course-v1:MITx+CTL.SC0x+3T2016/courseware/6a31d02d958e45a398d8a5f1592bdd78/b1ede7bf43c248e19894040718443750/1?activate_block_id=block-v1%3AMITx%2BCTL.SC0x%2B3T2016%2Btype%40vertical%2Bblock%400586b59f1cf74e3c982f0b9070e7ad33
### Big xblock
- ID: block-v1:MITx+CTL.SC0x+3T2016+type@vertical+block@84d6e785f548431a9e82e58d2df4e971
- URL: https://courses.edx.org/courses/course-v1:MITx+CTL.SC0x+3T2016/courseware/b77abc02967e401ca615b23dacf8d115/4913db3e36f14ccd8c98c374b9dae809/2?activate_block_id=block-v1%3AMITx%2BCTL.SC0x%2B3T2016%2Btype%40vertical%2Bblock%4084d6e785f548431a9e82e58d2df4e971
## Small course: course-v1:edX+DevSec101+3T2018
- URL: https://courses.edx.org/courses/course-v1:edX+DevSec101+3T2018/course/
- MFE URL: https://learning.edx.org/course/course-v1%3AedX%2BDevSec101%2B3T2018/0
### Small xblock
- ID: block-v1:edX+DevSec101+3T2018+type@vertical+block@931f96d1822a4fe5b521fcda19245dca
- URL: https://courses.edx.org/courses/course-v1:edX+DevSec101+3T2018/courseware/ee898e64bd174e4aba4c07cd2673e5d3/1a37309647814ab8b333c7a17d50abc4/1?activate_block_id=block-v1%3AedX%2BDevSec101%2B3T2018%2Btype%40vertical%2Bblock%40931f96d1822a4fe5b521fcda19245dca
### Big-ish xblock
- ID: block-v1:edX+DevSec101+3T2018+type@vertical+block@d88210fbc2b74ceab167a52def04e2a0
- URL: https://courses.edx.org/courses/course-v1:edX+DevSec101+3T2018/courseware/b0e2c2b78b5d49308e1454604a255403/38c7049bc8e44d309ab3bdb7f54ae6ae/2?activate_block_id=block-v1%3AedX%2BDevSec101%2B3T2018%2Btype%40vertical%2Bblock%40d88210fbc2b74ceab167a52def04e2a0

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

383
package-lock.json generated
View File

@@ -2686,15 +2686,15 @@
}
},
"@edx/paragon": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-7.2.0.tgz",
"integrity": "sha512-rz8sTNwJVahnlG7IQeodzwxQws+SHZEzMsLdWoxv2qu6atBMdDYnrhgObT6E/mtc2UpRnN6QPJFmbmHiVvlQkQ==",
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-7.2.1.tgz",
"integrity": "sha512-5TUrMj4Wry0PAFF/uZp8xWBzNOCc6UB4W04NqjmTlJyPRI0fZgKc7+aIQeI6jIHR8GsjTUwUzEMgZ2+aMyCu4A==",
"requires": {
"@fortawesome/fontawesome-svg-core": "^1.2.21",
"@fortawesome/free-solid-svg-icons": "^5.10.1",
"@fortawesome/react-fontawesome": "^0.1.4",
"airbnb-prop-types": "^2.12.0",
"bootstrap": "^4.3.1",
"bootstrap": "^4.4.1",
"classnames": "^2.2.6",
"email-prop-type": "^3.0.0",
"font-awesome": "^4.7.0",
@@ -4100,7 +4100,8 @@
"array-uniq": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz",
"integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY="
"integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=",
"dev": true
},
"array-unique": {
"version": "0.3.2",
@@ -4109,12 +4110,65 @@
"dev": true
},
"array.prototype.find": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.1.0.tgz",
"integrity": "sha512-Wn41+K1yuO5p7wRZDl7890c3xvv5UBrfVXTVIe28rSQb6LS0fZMDrQB6PAcxQFRFy6vJTLDc3A2+3CjQdzVKRg==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.1.1.tgz",
"integrity": "sha512-mi+MYNJYLTx2eNYy+Yh6raoQacCsNeeMUaspFPh9Y141lFSsWxxB8V9mM2ye+eqiRs917J6/pJ4M9ZPzenWckA==",
"requires": {
"define-properties": "^1.1.3",
"es-abstract": "^1.13.0"
"es-abstract": "^1.17.4"
},
"dependencies": {
"es-abstract": {
"version": "1.17.5",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
"integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
"requires": {
"es-to-primitive": "^1.2.1",
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.1",
"is-callable": "^1.1.5",
"is-regex": "^1.0.5",
"object-inspect": "^1.7.0",
"object-keys": "^1.1.1",
"object.assign": "^4.1.0",
"string.prototype.trimleft": "^2.1.1",
"string.prototype.trimright": "^2.1.1"
}
},
"is-callable": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
"integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q=="
},
"is-regex": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz",
"integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==",
"requires": {
"has": "^1.0.3"
}
},
"string.prototype.trimleft": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
"integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
"requires": {
"define-properties": "^1.1.3",
"es-abstract": "^1.17.5",
"string.prototype.trimstart": "^1.0.0"
}
},
"string.prototype.trimright": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
"integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
"requires": {
"define-properties": "^1.1.3",
"es-abstract": "^1.17.5",
"string.prototype.trimend": "^1.0.0"
}
}
}
},
"array.prototype.flat": {
@@ -6556,9 +6610,9 @@
}
},
"csstype": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.9.tgz",
"integrity": "sha512-xz39Sb4+OaTsULgUERcCk+TJj8ylkL4aSVDQiX/ksxbELSqwkgt4d4RD7fovIdgJGSuNYqwZEiVjYY5l0ask+Q=="
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.7.tgz",
"integrity": "sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ=="
},
"currently-unhandled": {
"version": "0.4.1",
@@ -7115,7 +7169,8 @@
"domelementtype": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
"integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w=="
"integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==",
"dev": true
},
"domexception": {
"version": "1.0.1",
@@ -7130,6 +7185,7 @@
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
"integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
"dev": true,
"requires": {
"domelementtype": "1"
}
@@ -7138,6 +7194,7 @@
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
"integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
"dev": true,
"requires": {
"dom-serializer": "0",
"domelementtype": "1"
@@ -7332,9 +7389,9 @@
}
},
"email-prop-type": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/email-prop-type/-/email-prop-type-3.0.0.tgz",
"integrity": "sha512-717XiCoLR3g6KIKbjim2OuBr7BKNgCiP74z/4DGt2xPsX1h3VlY1GiKy42Qy1EqONfeg/3FhtgaUcsAy7YcqHQ==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/email-prop-type/-/email-prop-type-3.0.1.tgz",
"integrity": "sha512-tONZGMEOOkadp5OBftuVXU8DsceWmINxYK+pqPFB4LT5ODjrPX/esel3WGqbV7d6in5/MnZE4n4QcqOr4gh7dg==",
"requires": {
"email-validator": "^2.0.4"
}
@@ -7433,7 +7490,8 @@
"entities": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz",
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w=="
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==",
"dev": true
},
"errno": {
"version": "0.1.7",
@@ -9691,14 +9749,66 @@
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"function.prototype.name": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.1.tgz",
"integrity": "sha512-e1NzkiJuw6xqVH7YSdiW/qDHebcmMhPNe6w+4ZYYEg0VA+LaLzx37RimbPLuonHhYGFGPx1ME2nSi74JiaCr/Q==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.2.tgz",
"integrity": "sha512-C8A+LlHBJjB2AdcRPorc5JvJ5VUoWlXdEHLOJdCI7kjHEtGTpHQUiqMvCIKUwIsGwZX2jZJy761AXsn356bJQg==",
"requires": {
"define-properties": "^1.1.3",
"function-bind": "^1.1.1",
"functions-have-names": "^1.1.1",
"is-callable": "^1.1.4"
"es-abstract": "^1.17.0-next.1",
"functions-have-names": "^1.2.0"
},
"dependencies": {
"es-abstract": {
"version": "1.17.5",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
"integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
"requires": {
"es-to-primitive": "^1.2.1",
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.1",
"is-callable": "^1.1.5",
"is-regex": "^1.0.5",
"object-inspect": "^1.7.0",
"object-keys": "^1.1.1",
"object.assign": "^4.1.0",
"string.prototype.trimleft": "^2.1.1",
"string.prototype.trimright": "^2.1.1"
}
},
"is-callable": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
"integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q=="
},
"is-regex": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz",
"integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==",
"requires": {
"has": "^1.0.3"
}
},
"string.prototype.trimleft": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
"integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
"requires": {
"define-properties": "^1.1.3",
"es-abstract": "^1.17.5",
"string.prototype.trimstart": "^1.0.0"
}
},
"string.prototype.trimright": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
"integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
"requires": {
"define-properties": "^1.1.3",
"es-abstract": "^1.17.5",
"string.prototype.trimend": "^1.0.0"
}
}
}
},
"functional-red-black-tree": {
@@ -9708,9 +9818,9 @@
"dev": true
},
"functions-have-names": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.0.tgz",
"integrity": "sha512-zKXyzksTeaCSw5wIX79iCA40YAa6CJMJgNg9wdkU/ERBrIdPSimPICYiLp65lRbSBqtiHql/HZfS2DyI/AH6tQ=="
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.1.tgz",
"integrity": "sha512-j48B/ZI7VKs3sgeI2cZp7WXWmZXu7Iq5pl5/vptV5N2mq+DGFuS/ulaDjtaoLpYzuD6u8UgrUKHfgo7fDTSiBA=="
},
"gauge": {
"version": "2.7.4",
@@ -10389,6 +10499,7 @@
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
"integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
"dev": true,
"requires": {
"domelementtype": "^1.3.1",
"domhandler": "^2.3.0",
@@ -13604,7 +13715,8 @@
"number-is-nan": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
"dev": true
},
"nwsapi": {
"version": "2.2.0",
@@ -14554,6 +14666,7 @@
"version": "7.0.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.23.tgz",
"integrity": "sha512-hOlMf3ouRIFXD+j2VJecwssTwbvsPGJVMzupptg+85WA+i7MwyrydmQAgY3R+m0Bc0exunhbJmijy8u8+vufuQ==",
"dev": true,
"requires": {
"chalk": "^2.4.2",
"source-map": "^0.6.1",
@@ -14564,6 +14677,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
"integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
"dev": true,
"requires": {
"has-flag": "^3.0.0"
}
@@ -15734,6 +15848,7 @@
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz",
"integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==",
"dev": true,
"requires": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
@@ -16488,7 +16603,8 @@
"safe-buffer": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz",
"integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg=="
"integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==",
"dev": true
},
"safe-regex": {
"version": "1.1.0",
@@ -16531,20 +16647,79 @@
}
},
"sanitize-html": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-1.20.1.tgz",
"integrity": "sha512-txnH8TQjaQvg2Q0HY06G6CDJLVYCpbnxrdO0WN8gjCKaU5J0KbyGYhZxx5QJg3WLZ1lB7XU9kDkfrCXUozqptA==",
"version": "1.22.1",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-1.22.1.tgz",
"integrity": "sha512-++IMC00KfMQc45UWZJlhWOlS9eMrME38sFG9GXfR+k6oBo9JXSYQgTOZCl9j3v/smFTRNT9XNwz5DseFdMY+2Q==",
"requires": {
"chalk": "^2.4.1",
"htmlparser2": "^3.10.0",
"htmlparser2": "^4.1.0",
"lodash.clonedeep": "^4.5.0",
"lodash.escaperegexp": "^4.1.2",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.mergewith": "^4.6.1",
"postcss": "^7.0.5",
"srcset": "^1.0.0",
"lodash.mergewith": "^4.6.2",
"postcss": "^7.0.27",
"srcset": "^2.0.1",
"xtend": "^4.0.1"
},
"dependencies": {
"domelementtype": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.1.tgz",
"integrity": "sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ=="
},
"domhandler": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.0.0.tgz",
"integrity": "sha512-eKLdI5v9m67kbXQbJSNn1zjh0SDzvzWVWtX+qEI3eMjZw8daH9k8rlj1FZY9memPwjiskQFbe7vHVVJIAqoEhw==",
"requires": {
"domelementtype": "^2.0.1"
}
},
"domutils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.0.0.tgz",
"integrity": "sha512-n5SelJ1axbO636c2yUtOGia/IcJtVtlhQbFiVDBZHKV5ReJO1ViX7sFEemtuyoAnBxk5meNSYgA8V4s0271efg==",
"requires": {
"dom-serializer": "^0.2.1",
"domelementtype": "^2.0.1",
"domhandler": "^3.0.0"
}
},
"entities": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz",
"integrity": "sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw=="
},
"htmlparser2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-4.1.0.tgz",
"integrity": "sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q==",
"requires": {
"domelementtype": "^2.0.1",
"domhandler": "^3.0.0",
"domutils": "^2.0.0",
"entities": "^2.0.0"
}
},
"postcss": {
"version": "7.0.27",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.27.tgz",
"integrity": "sha512-WuQETPMcW9Uf1/22HWUWP9lgsIC+KEHg2kozMflKjbeUtw9ujvFX6QmIfozaErDkmLWS9WEnEdEe6Uo9/BNTdQ==",
"requires": {
"chalk": "^2.4.2",
"source-map": "^0.6.1",
"supports-color": "^6.1.0"
}
},
"supports-color": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
"integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
"requires": {
"has-flag": "^3.0.0"
}
}
}
},
"sass-graph": {
@@ -17633,13 +17808,9 @@
}
},
"srcset": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/srcset/-/srcset-1.0.0.tgz",
"integrity": "sha1-pWad4StC87HV6D7QPHEEb8SPQe8=",
"requires": {
"array-uniq": "^1.0.2",
"number-is-nan": "^1.0.0"
}
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/srcset/-/srcset-2.0.1.tgz",
"integrity": "sha512-00kZI87TdRKwt+P8jj8UZxbfp7mK2ufxcIMWvhAOZNJTRROimpHeruWrGvCZneiuVDLqdyHefVp748ECTnyUBQ=="
},
"sshpk": {
"version": "1.16.1",
@@ -17927,6 +18098,68 @@
}
}
},
"string.prototype.trimend": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.0.tgz",
"integrity": "sha512-EEJnGqa/xNfIg05SxiPSqRS7S9qwDhYts1TSLR1BQfYUfPe1stofgGKvwERK9+9yf+PpfBMlpBaCHucXGPQfUA==",
"requires": {
"define-properties": "^1.1.3",
"es-abstract": "^1.17.5"
},
"dependencies": {
"es-abstract": {
"version": "1.17.5",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
"integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
"requires": {
"es-to-primitive": "^1.2.1",
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.1",
"is-callable": "^1.1.5",
"is-regex": "^1.0.5",
"object-inspect": "^1.7.0",
"object-keys": "^1.1.1",
"object.assign": "^4.1.0",
"string.prototype.trimleft": "^2.1.1",
"string.prototype.trimright": "^2.1.1"
}
},
"is-callable": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
"integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q=="
},
"is-regex": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz",
"integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==",
"requires": {
"has": "^1.0.3"
}
},
"string.prototype.trimleft": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
"integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
"requires": {
"define-properties": "^1.1.3",
"es-abstract": "^1.17.5",
"string.prototype.trimstart": "^1.0.0"
}
},
"string.prototype.trimright": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
"integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
"requires": {
"define-properties": "^1.1.3",
"es-abstract": "^1.17.5",
"string.prototype.trimend": "^1.0.0"
}
}
}
},
"string.prototype.trimleft": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.0.tgz",
@@ -17945,10 +18178,73 @@
"function-bind": "^1.1.1"
}
},
"string.prototype.trimstart": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.0.tgz",
"integrity": "sha512-iCP8g01NFYiiBOnwG1Xc3WZLyoo+RuBymwIlWncShXDDJYWN6DbnM3odslBJdgCdRlq94B5s63NWAZlcn2CS4w==",
"requires": {
"define-properties": "^1.1.3",
"es-abstract": "^1.17.5"
},
"dependencies": {
"es-abstract": {
"version": "1.17.5",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
"integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
"requires": {
"es-to-primitive": "^1.2.1",
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.1",
"is-callable": "^1.1.5",
"is-regex": "^1.0.5",
"object-inspect": "^1.7.0",
"object-keys": "^1.1.1",
"object.assign": "^4.1.0",
"string.prototype.trimleft": "^2.1.1",
"string.prototype.trimright": "^2.1.1"
}
},
"is-callable": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
"integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q=="
},
"is-regex": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz",
"integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==",
"requires": {
"has": "^1.0.3"
}
},
"string.prototype.trimleft": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
"integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
"requires": {
"define-properties": "^1.1.3",
"es-abstract": "^1.17.5",
"string.prototype.trimstart": "^1.0.0"
}
},
"string.prototype.trimright": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
"integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
"requires": {
"define-properties": "^1.1.3",
"es-abstract": "^1.17.5",
"string.prototype.trimend": "^1.0.0"
}
}
}
},
"string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"requires": {
"safe-buffer": "~5.2.0"
}
@@ -19144,7 +19440,8 @@
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
"dev": true
},
"util.promisify": {
"version": "1.0.0",

View File

@@ -37,7 +37,7 @@
"@edx/frontend-component-footer": "^10.0.6",
"@edx/frontend-component-header": "^2.0.3",
"@edx/frontend-platform": "^1.3.1",
"@edx/paragon": "^7.2.0",
"@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",
@@ -52,7 +52,6 @@
"react-redux": "^7.1.3",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"react-transition-group": "^4.3.0",
"redux": "^4.0.5",
"regenerator-runtime": "^0.13.3"
},

View File

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

View File

@@ -33,5 +33,5 @@ export default class PageLoading extends Component {
}
PageLoading.propTypes = {
srMessage: PropTypes.string.isRequired,
srMessage: PropTypes.node.isRequired,
};

View File

@@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from '../user-messages';
function AccessExpirationAlert(props) {
const {
rawHtml,
} = props;
return rawHtml && (
<Alert type="info">
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
</Alert>
);
}
AccessExpirationAlert.propTypes = {
rawHtml: PropTypes.string.isRequired,
};
export default AccessExpirationAlert;

View File

@@ -0,0 +1,28 @@
/* eslint-disable import/prefer-default-export */
import { useContext, useState, useEffect } from 'react';
import { UserMessagesContext } from '../user-messages';
import { useModel } from '../model-store';
export function useAccessExpirationAlert(courseId) {
const course = useModel('courses', courseId);
const { add, remove } = useContext(UserMessagesContext);
const [alertId, setAlertId] = useState(null);
const rawHtml = (course && course.courseExpiredMessage) || null;
useEffect(() => {
if (rawHtml && alertId === null) {
setAlertId(add({
code: 'clientAccessExpirationAlert',
topic: 'course',
rawHtml,
}));
} else if (!rawHtml && alertId !== null) {
remove(alertId);
setAlertId(null);
}
return () => {
if (alertId !== null) {
remove(alertId);
}
};
}, [alertId, courseId, rawHtml]);
}

View File

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

View File

@@ -4,28 +4,28 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
import messages from './messages';
import NavTab from './NavTab';
import Tabs from '../tabs/Tabs';
function CourseTabsNavigation({
activeTabSlug, tabs, intl, className,
activeTabSlug, className, tabs, intl,
}) {
const courseNavTabs = tabs.map(({ slug, ...courseTab }) => (
<NavTab
isActive={slug === activeTabSlug}
key={slug}
{...courseTab}
/>
));
return (
<div className="course-tabs-navigation">
<div className={classNames('course-tabs-navigation', className)}>
<div className="container-fluid">
<nav
<Tabs
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages['learn.navigation.course.tabs.label'])}
className={classNames('nav nav-underline-tabs', className)}
>
{courseNavTabs}
</nav>
{tabs.map(({ url, title, slug }) => (
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
href={url}
>
{title}
</a>
))}
</Tabs>
</div>
</div>
);

View File

@@ -4,7 +4,10 @@ import { Dropdown } from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { AppContext } from '@edx/frontend-platform/react';
import logo from './logo.svg';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
import logo from './assets/logo.svg';
function LinkedLogo({
href,
@@ -25,13 +28,13 @@ LinkedLogo.propTypes = {
alt: PropTypes.string.isRequired,
};
export default function CourseHeader({
courseOrg, courseNumber, courseName,
export default function Header({
courseOrg, courseNumber, courseTitle,
}) {
const { authenticatedUser } = useContext(AppContext);
return (
<header>
<header className="course-header">
<div className="container-fluid py-2 d-flex align-items-center ">
<LinkedLogo
className="logo"
@@ -39,14 +42,17 @@ export default function CourseHeader({
src={logo}
alt={getConfig().SITE_NAME}
/>
<div className="flex-grow-1" style={{ lineHeight: 1 }}>
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
<span className="d-block m-0 font-weight-bold">{courseName}</span>
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
</div>
<Dropdown>
<Dropdown className="user-dropdown">
<Dropdown.Button>
{authenticatedUser.username}
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
<span className="d-none d-md-inline">
{authenticatedUser.username}
</span>
</Dropdown.Button>
<Dropdown.Menu className="dropdown-menu-right">
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>Dashboard</Dropdown.Item>
@@ -61,8 +67,14 @@ export default function CourseHeader({
);
}
CourseHeader.propTypes = {
courseOrg: PropTypes.string.isRequired,
courseNumber: PropTypes.string.isRequired,
courseName: PropTypes.string.isRequired,
Header.propTypes = {
courseOrg: PropTypes.string,
courseNumber: PropTypes.string,
courseTitle: PropTypes.string,
};
Header.defaultProps = {
courseOrg: null,
courseNumber: null,
courseTitle: null,
};

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,2 @@
export { default as Header } from './Header';
export { default as CourseTabsNavigation } from './CourseTabsNavigation';

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { Button } from '@edx/paragon';
import { AlertList } from '../user-messages';
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() {
const {
courseId,
} = useSelector(state => state.courseware);
const {
title,
start,
end,
enrollmentStart,
enrollmentEnd,
enrollmentMode,
isEnrolled,
sectionIds,
} = useModel('courses', courseId);
return (
<>
<AlertList
topic="outline"
className="mb-3"
customAlerts={{
clientEnrollmentAlert: EnrollmentAlert,
clientStaffEnrollmentAlert: StaffEnrollmentAlert,
clientLogistrationAlert: LogistrationAlert,
}}
/>
<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>
</>
);
}

View File

@@ -4,10 +4,11 @@ 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 { courseBlocksShape } from '../data/course-blocks';
import { useModel } from '../model-store';
export default function Chapter({ id, courseUsageKey, models }) {
const { displayName, children } = models[id];
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">
@@ -21,16 +22,15 @@ export default function Chapter({ id, courseUsageKey, models }) {
<FontAwesomeIcon icon={faChevronDown} />
</div>
</Collapsible.Visible>
<div className="ml-2 flex-grow-1">{displayName}</div>
<div className="ml-2 flex-grow-1">{title}</div>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body">
{children.map((sequenceId) => (
{sequenceIds.map((sequenceId) => (
<SequenceLink
key={sequenceId}
id={sequenceId}
courseUsageKey={courseUsageKey}
models={models}
courseId={courseId}
/>
))}
</Collapsible.Body>
@@ -38,8 +38,7 @@ export default function Chapter({ id, courseUsageKey, models }) {
);
}
Chapter.propTypes = {
Section.propTypes = {
id: PropTypes.string.isRequired,
courseUsageKey: PropTypes.string.isRequired,
models: courseBlocksShape.isRequired,
courseId: PropTypes.string.isRequired,
};

View File

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

1
src/course-home/index.js Normal file
View File

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

View File

@@ -1,110 +0,0 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { history, getConfig } from '@edx/frontend-platform';
import { fetchCourseMetadata, courseMetadataShape } from '../data/course-meta';
import { fetchCourseBlocks } from '../data/course-blocks';
import messages from './messages';
import PageLoading from '../PageLoading';
import Course from './course/Course';
function CourseContainer(props) {
const {
intl,
match,
courseId,
blocks: models,
metadata,
} = props;
const {
courseUsageKey,
sequenceId,
unitId,
} = match.params;
useEffect(() => {
props.fetchCourseMetadata(courseUsageKey);
props.fetchCourseBlocks(courseUsageKey);
}, [courseUsageKey]);
useEffect(() => {
if (courseId && !sequenceId) {
// TODO: This is temporary until we get an actual activeSequenceId into the course model data.
const course = models[courseId];
const chapter = models[course.children[0]];
const activeSequenceId = chapter.children[0];
history.push(`/course/${courseUsageKey}/${activeSequenceId}`);
}
}, [courseUsageKey, courseId, sequenceId]);
const metadataLoaded = metadata.fetchState === 'loaded';
useEffect(() => {
if (metadataLoaded && !metadata.userHasAccess) {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${courseUsageKey}/course/`);
}
}, [metadataLoaded]);
// Whether or not the container is ready to render the Course.
const ready = metadataLoaded && courseId && sequenceId;
return (
<main className="flex-grow-1 d-flex flex-column">
{ready ? (
<Course
courseOrg={props.metadata.org}
courseNumber={props.metadata.number}
courseName={props.metadata.name}
courseUsageKey={courseUsageKey}
courseId={courseId}
isEnrolled={props.metadata.isEnrolled}
sequenceId={sequenceId}
unitId={unitId}
models={models}
tabs={props.metadata.tabs}
verifiedMode={props.metadata.verifiedMode}
/>
) : (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
/>
)}
</main>
);
}
CourseContainer.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string,
blocks: PropTypes.objectOf(PropTypes.shape({
id: PropTypes.string,
})),
metadata: courseMetadataShape,
fetchCourseMetadata: PropTypes.func.isRequired,
fetchCourseBlocks: PropTypes.func.isRequired,
match: PropTypes.shape({
params: PropTypes.shape({
courseUsageKey: PropTypes.string.isRequired,
sequenceId: PropTypes.string,
unitId: PropTypes.string,
}).isRequired,
}).isRequired,
};
CourseContainer.defaultProps = {
blocks: {},
metadata: undefined,
courseId: undefined,
};
const mapStateToProps = state => ({
courseId: state.courseBlocks.root,
metadata: state.courseMeta,
blocks: state.courseBlocks.blocks,
});
export default connect(mapStateToProps, {
fetchCourseMetadata,
fetchCourseBlocks,
})(injectIntl(CourseContainer));

View File

@@ -0,0 +1,229 @@
import React, { useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { useSelector, useDispatch } from 'react-redux';
import { history } from '@edx/frontend-platform';
import { getLocale } from '@edx/frontend-platform/i18n';
import { useRouteMatch, Redirect } from 'react-router';
import {
fetchCourse,
fetchSequence,
getResumeBlock,
} from '../data';
import {
checkBlockCompletion,
saveSequencePosition,
} from './data/thunks';
import { useModel } from '../model-store';
import { TabPage } from '../tab-page';
import Course from './course';
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, unitId]);
}
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) {
if (nextSequence.unitIds.length > 0) {
const nextUnitId = nextSequence.unitIds[0];
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}`);
}
}
}, [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) {
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}`);
}
}
}, [courseStatus, sequenceStatus, sequenceId]);
}
function useExamRedirect(sequenceId) {
const sequence = useModel('sequences', sequenceId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
useEffect(() => {
if (sequenceStatus === 'loaded' && sequence.isTimeLimited) {
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) {
getResumeBlock(courseId).then((data) => {
// This is a replace because we don't want this change saved in the browser's history.
if (data.sectionId && data.unitId) {
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
} else {
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}`);
}
}
}, [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 activeUnitIndex = sequence.unitIds.indexOf(unitId);
dispatch(saveSequencePosition(courseId, sequenceId, activeUnitIndex));
}
}, [unitId]);
}
export default function CoursewareContainer() {
const { params } = useRouteMatch();
const {
courseId: routeCourseUsageKey,
sequenceId: routeSequenceId,
unitId: routeUnitId,
} = params;
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchCourse(routeCourseUsageKey));
}, [routeCourseUsageKey]);
useEffect(() => {
if (routeSequenceId) {
dispatch(fetchSequence(routeSequenceId));
}
}, [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);
const course = useModel('courses', courseId);
if (courseStatus === 'denied') {
switch (course.canLoadCourseware.errorCode) {
case 'audit_expired':
return <Redirect to={`/redirect/dashboard?access_response_error=${course.canLoadCourseware.additionalContextUserMessage}`} />;
case 'course_not_started':
// eslint-disable-next-line no-case-declarations
const startDate = (new Intl.DateTimeFormat(getLocale())).format(new Date(course.start));
return <Redirect to={`/redirect/dashboard?notlive=${startDate}`} />;
case 'survey_required': // TODO: Redirect to the course survey
case 'unfulfilled_milestones':
return <Redirect to="/redirect/dashboard" />;
case 'authentication_required':
case 'enrollment_required':
default:
return <Redirect to={`/redirect/course-home/${courseId}`} />;
}
}
return (
<TabPage
activeTabSlug="courseware"
courseId={courseId}
unitId={routeUnitId}
>
<Course
courseId={courseId}
sequenceId={sequenceId}
unitId={routeUnitId}
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
unitNavigationHandler={unitNavigationHandler}
/>
</TabPage>
);
}
CoursewareContainer.propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string,
unitId: PropTypes.string,
}).isRequired,
}).isRequired,
};

View File

@@ -1,46 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
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-flex justify-content-end align-items-center">
<div>
<p className="mb-0 mr-5">
You are currently previewing the new learning sequence experience. 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>.
</p>
</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.courseBlocks.blocks[props.unitId];
return {
activeUnitLmsWebUrl: activeUnit.lmsWebUrl,
};
};
export default connect(mapStateToProps)(InstructorToolbar);

View File

@@ -1,138 +1,97 @@
import React, { useCallback } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { history } from '@edx/frontend-platform';
import { AlertList } from '../../user-messages';
import { useAccessExpirationAlert } from '../../access-expiration-alert';
import { useOfferAlert } from '../../offer-alert';
import Sequence from './sequence';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import SequenceContainer from './SequenceContainer';
import { createSequenceIdList } from '../utils';
import AlertList from '../../user-messages/AlertList';
import CourseHeader from './CourseHeader';
import CourseSock from './course-sock';
import CourseTabsNavigation from './CourseTabsNavigation';
import InstructorToolbar from '../InstructorToolbar';
import { useLogistrationAlert, useEnrollmentAlert } from '../../hooks';
import ContentTools from './tools/ContentTools';
import { useModel } from '../../model-store';
const EnrollmentAlert = React.lazy(() => import('../../enrollment-alert'));
// 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 AccessExpirationAlert = React.lazy(() => import('../../access-expiration-alert/AccessExpirationAlert'));
const EnrollmentAlert = React.lazy(() => import('../../enrollment-alert/EnrollmentAlert'));
const StaffEnrollmentAlert = React.lazy(() => import('../../enrollment-alert/StaffEnrollmentAlert'));
const LogistrationAlert = React.lazy(() => import('../../logistration-alert'));
const OfferAlert = React.lazy(() => import('../../offer-alert/OfferAlert'));
export default function Course({
function Course({
courseId,
courseNumber,
courseName,
courseOrg,
courseUsageKey,
isEnrolled,
models,
sequenceId,
tabs,
unitId,
verifiedMode,
nextSequenceHandler,
previousSequenceHandler,
unitNavigationHandler,
}) {
const nextSequenceHandler = useCallback(() => {
const sequenceIds = createSequenceIdList(models, courseId);
const currentIndex = sequenceIds.indexOf(sequenceId);
if (currentIndex < sequenceIds.length - 1) {
const nextSequenceId = sequenceIds[currentIndex + 1];
const nextSequence = models[nextSequenceId];
const nextUnitId = nextSequence.children[0];
history.push(`/course/${courseUsageKey}/${nextSequenceId}/${nextUnitId}`);
}
});
const course = useModel('courses', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sequence ? sequence.sectionId : null);
const previousSequenceHandler = useCallback(() => {
const sequenceIds = createSequenceIdList(models, courseId);
const currentIndex = sequenceIds.indexOf(sequenceId);
if (currentIndex > 0) {
const previousSequenceId = sequenceIds[currentIndex - 1];
const previousSequence = models[previousSequenceId];
const previousUnitId = previousSequence.children[previousSequence.children.length - 1];
history.push(`/course/${courseUsageKey}/${previousSequenceId}/${previousUnitId}`);
}
});
useOfferAlert(courseId);
useAccessExpirationAlert(courseId);
useLogistrationAlert();
useEnrollmentAlert(isEnrolled);
const {
canShowUpgradeSock,
verifiedMode,
} = course;
return (
<>
<CourseHeader
courseOrg={courseOrg}
courseNumber={courseNumber}
courseName={courseName}
<AlertList
className="my-3"
topic="course"
customAlerts={{
clientEnrollmentAlert: EnrollmentAlert,
clientStaffEnrollmentAlert: StaffEnrollmentAlert,
clientLogistrationAlert: LogistrationAlert,
clientAccessExpirationAlert: AccessExpirationAlert,
clientOfferAlert: OfferAlert,
}}
// courseId is provided because EnrollmentAlert and StaffEnrollmentAlert require it.
customProps={{
courseId,
}}
/>
<InstructorToolbar
courseUsageKey={courseUsageKey}
<CourseBreadcrumbs
courseId={courseId}
sectionId={section ? section.id : null}
sequenceId={sequenceId}
unitId={unitId}
/>
<CourseTabsNavigation tabs={tabs} activeTabSlug="courseware" />
<div className="container-fluid flex-grow-1 d-flex flex-column">
<AlertList
className="my-3"
topic="course"
customAlerts={{
clientEnrollmentAlert: EnrollmentAlert,
clientLogistrationAlert: LogistrationAlert,
}}
/>
<CourseBreadcrumbs
courseUsageKey={courseUsageKey}
courseId={courseId}
sequenceId={sequenceId}
unitId={unitId}
models={models}
/>
<SequenceContainer
key={sequenceId}
courseUsageKey={courseUsageKey}
courseId={courseId}
sequenceId={sequenceId}
unitId={unitId}
models={models}
onNext={nextSequenceHandler}
onPrevious={previousSequenceHandler}
/>
</div>
{verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
<AlertList topic="sequence" />
<Sequence
unitId={unitId}
sequenceId={sequenceId}
courseId={courseId}
unitNavigationHandler={unitNavigationHandler}
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
/>
{canShowUpgradeSock && verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
<ContentTools course={course} />
</>
);
}
Course.propTypes = {
courseOrg: PropTypes.string.isRequired,
courseNumber: PropTypes.string.isRequired,
courseName: PropTypes.string.isRequired,
courseUsageKey: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string.isRequired,
courseId: PropTypes.string,
sequenceId: PropTypes.string,
unitId: PropTypes.string,
isEnrolled: PropTypes.bool,
models: PropTypes.objectOf(PropTypes.shape({
id: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
children: PropTypes.arrayOf(PropTypes.string),
parentId: PropTypes.string,
})).isRequired,
tabs: PropTypes.arrayOf(PropTypes.shape({
slug: PropTypes.string.isRequired,
priority: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
})).isRequired,
verifiedMode: PropTypes.shape({
price: PropTypes.number.isRequired,
currency: PropTypes.string.isRequired,
currencySymbol: PropTypes.string,
sku: PropTypes.string.isRequired,
upgradeUrl: PropTypes.string.isRequired,
}),
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
unitNavigationHandler: PropTypes.func.isRequired,
};
Course.defaultProps = {
unitId: undefined,
isEnrolled: false,
verifiedMode: null,
courseId: null,
sequenceId: null,
unitId: null,
};
export default Course;

View File

@@ -1,20 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
export default function CourseBreadcrumb({ url, label }) {
return (
<React.Fragment key={`${label}-${url}`}>
<li className="list-inline-item text-gray-300" role="presentation" aria-label="spacer">
/
</li>
<li className="list-inline-item">
<a href={url}>{label}</a>
</li>
</React.Fragment>
);
}
CourseBreadcrumb.propTypes = {
url: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
};

View File

@@ -4,39 +4,83 @@ import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHome } from '@fortawesome/free-solid-svg-icons';
import { useSelector } from 'react-redux';
import { useModel } from '../../model-store';
import CourseBreadcrumb from './CourseBreadcrumb';
function CourseBreadcrumb({
url, children, withSeparator, ...attrs
}) {
return (
<>
{withSeparator && (
<li className="mx-2 text-gray-300" role="presentation" aria-hidden>/</li>
)}
<li {...attrs}>
<a href={url}>{children}</a>
</li>
</>
);
}
CourseBreadcrumb.propTypes = {
url: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
withSeparator: PropTypes.bool,
};
CourseBreadcrumb.defaultProps = {
withSeparator: false,
};
export default function CourseBreadcrumbs({
courseUsageKey, courseId, sequenceId, models,
courseId,
sectionId,
sequenceId,
}) {
const course = useModel('courses', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sectionId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const links = useMemo(() => {
const sectionId = models[sequenceId].parentId;
return [sectionId, sequenceId].map((nodeId) => {
const node = models[nodeId];
return {
if (courseStatus === 'loaded' && sequenceStatus === 'loaded') {
return [section, sequence].filter(node => !!node).map((node) => ({
id: node.id,
label: node.displayName,
url: `${getConfig().LMS_BASE_URL}/courses/${courseUsageKey}/course/#${node.id}`,
};
});
}, [courseUsageKey, courseId, sequenceId, models]);
label: node.title,
url: `${getConfig().LMS_BASE_URL}/courses/${course.id}/course/#${node.id}`,
}));
}
return [];
}, [courseStatus, sequenceStatus]);
return (
<nav aria-label="breadcrumb" className="my-4">
<ol className="list-inline m-0">
<li className="list-inline-item">
<a href={`${getConfig().LMS_BASE_URL}/courses/${courseUsageKey}/course/`}>
<FontAwesomeIcon icon={faHome} className="mr-2" />
<FormattedMessage
id="learn.breadcrumb.navigation.course.home"
description="The course home link in breadcrumbs nav"
defaultMessage="Course"
/>
</a>
</li>
{links.map(({ id, url, label }, i) => (
<CourseBreadcrumb key={id} url={url} label={label} last={i === links.length - 1} />
<ol className="list-unstyled d-flex m-0">
<CourseBreadcrumb
url={`${getConfig().LMS_BASE_URL}/courses/${course.id}/course/`}
className="flex-shrink-0"
>
<FontAwesomeIcon icon={faHome} className="mr-2" />
<FormattedMessage
id="learn.breadcrumb.navigation.course.home"
description="The course home link in breadcrumbs nav"
defaultMessage="Course"
/>
</CourseBreadcrumb>
{links.map(({ id, url, label }) => (
<CourseBreadcrumb
key={id}
url={url}
withSeparator
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{label}
</CourseBreadcrumb>
))}
</ol>
</nav>
@@ -44,13 +88,12 @@ export default function CourseBreadcrumbs({
}
CourseBreadcrumbs.propTypes = {
courseUsageKey: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string.isRequired,
models: PropTypes.objectOf(PropTypes.shape({
id: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
children: PropTypes.arrayOf(PropTypes.string),
parentId: PropTypes.string,
})).isRequired,
sectionId: PropTypes.string,
sequenceId: PropTypes.string,
};
CourseBreadcrumbs.defaultProps = {
sectionId: null,
sequenceId: null,
};

View File

@@ -0,0 +1,94 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
function getInsightsUrl(courseId) {
const urlBase = getConfig().INSIGHTS_BASE_URL;
let urlFull;
if (urlBase) {
urlFull = `${urlBase}/courses`;
// This shouldn't actually be missing, at present,
// but we're providing a reasonable fallback,
// in case of either error or extension.
if (courseId) {
urlFull += `/${courseId}`;
}
}
return urlFull;
}
function getStudioUrl(courseId, unitId) {
const urlBase = getConfig().STUDIO_BASE_URL;
let urlFull;
if (urlBase) {
if (unitId) {
urlFull = `${urlBase}/container/${unitId}`;
} else if (courseId) {
urlFull = `{$urlBase}/course/${courseId}`;
}
}
return urlFull;
}
function InstructorToolbar(props) {
const {
courseId,
unitId,
} = props;
const urlInsights = getInsightsUrl(courseId);
const urlLms = props.activeUnitLmsWebUrl;
const urlStudio = getStudioUrl(courseId, unitId);
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">
&nbsp;
</div>
{urlLms && (
<div className="flex-shrink-0">
<a className="btn d-block btn-outline-light" href={urlLms}>View in the existing experience</a>
</div>
)}
&nbsp;
{urlStudio && (
<div className="flex-shrink-0">
<a className="btn d-block btn-outline-light" href={urlStudio}>View in Studio</a>
</div>
)}
&nbsp;
{urlInsights && (
<div className="flex-shrink-0">
<a className="btn d-block btn-outline-light" href={urlInsights}>View in Insights</a>
</div>
)}
</div>
</div>
);
}
InstructorToolbar.propTypes = {
activeUnitLmsWebUrl: PropTypes.string,
courseId: PropTypes.string,
unitId: PropTypes.string,
};
InstructorToolbar.defaultProps = {
activeUnitLmsWebUrl: undefined,
courseId: undefined,
unitId: 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

@@ -1,246 +0,0 @@
import React, {
useState, useCallback, useRef, useEffect,
} from 'react';
import { CSSTransition } from 'react-transition-group';
import PropTypes from 'prop-types';
export function MenuTrigger({ tag, className, ...attributes }) {
return React.createElement(tag, {
className: `menu-trigger ${className}`,
...attributes,
});
}
MenuTrigger.propTypes = {
tag: PropTypes.string,
className: PropTypes.string,
};
MenuTrigger.defaultProps = {
tag: 'div',
className: null,
};
const MenuTriggerType = (<MenuTrigger />).type;
export function MenuContent({ tag, className, ...attributes }) {
return React.createElement(tag, {
className: ['menu-content', className].join(' '),
...attributes,
});
}
MenuContent.propTypes = {
tag: PropTypes.string,
className: PropTypes.string,
};
MenuContent.defaultProps = {
tag: 'div',
className: null,
};
export function Menu({
className,
children,
tag,
transitionTimeout,
transitionClassName,
respondToPointerEvents,
onOpen,
onClose,
closeOnDocumentClick,
...attributes
}) {
const [expanded, setExpanded] = useState(false);
const menu = useRef(null);
const open = useCallback(() => {
if (onOpen) {
onOpen();
}
setExpanded(true);
}, [onOpen]);
const close = useCallback(() => {
if (onClose) {
onClose();
}
setExpanded(false);
}, [onClose]);
const toggle = useCallback(() => {
if (expanded) {
close();
} else {
open();
}
}, [expanded]);
const onDocumentClick = useCallback((e) => {
if (!closeOnDocumentClick) {
return;
}
const clickIsInMenu = menu.current === e.target || menu.current.contains(e.target);
if (clickIsInMenu) {
return;
}
close();
}, [closeOnDocumentClick]);
useEffect(() => {
if (expanded) {
// Listen to touchend and click events to ensure the menu
// can be closed on mobile, pointer, and mixed input devices
document.addEventListener('touchend', onDocumentClick, true);
document.addEventListener('click', onDocumentClick, true);
} else {
document.removeEventListener('touchend', onDocumentClick, true);
document.removeEventListener('click', onDocumentClick, true);
}
return () => {
document.removeEventListener('touchend', onDocumentClick, true);
document.removeEventListener('click', onDocumentClick, true);
};
}, [expanded]);
const onTriggerClick = useCallback((e) => {
// Let the browser follow the link of the trigger if the menu
// is already expanded and the trigger has an href attribute
if (expanded && e.target.getAttribute('href')) {
return;
}
e.preventDefault();
toggle();
}, []);
const onMouseEnter = useCallback(() => {
if (!respondToPointerEvents) { return; }
open();
}, [respondToPointerEvents]);
const onMouseLeave = useCallback(() => {
if (!respondToPointerEvents) { return; }
close();
}, [respondToPointerEvents]);
const getFocusableElements = useCallback(() => menu.current.querySelectorAll('button:not([disabled]), [href]:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])'), []);
const focusNext = useCallback(() => {
const focusableElements = Array.from(getFocusableElements());
const activeIndex = focusableElements.indexOf(document.activeElement);
const nextIndex = (activeIndex + 1) % focusableElements.length;
focusableElements[nextIndex].focus();
}, []);
const focusPrevious = useCallback(() => {
const focusableElements = Array.from(getFocusableElements());
const activeIndex = focusableElements.indexOf(document.activeElement);
const previousIndex = (activeIndex || focusableElements.length) - 1;
focusableElements[previousIndex].focus();
}, []);
const onKeyDown = useCallback((e) => {
if (!expanded) {
return;
}
switch (e.key) {
case 'Escape': {
e.preventDefault();
e.stopPropagation();
getFocusableElements()[0].focus();
close();
break;
}
case 'Enter': {
// Using focusable elements instead of a ref to the trigger
// because Hyperlink and Button can handle refs as functional components
if (document.activeElement === getFocusableElements()[0]) {
e.preventDefault();
toggle();
}
break;
}
case 'Tab': {
e.preventDefault();
if (e.shiftKey) {
focusPrevious();
} else {
focusNext();
}
break;
}
case 'ArrowDown': {
e.preventDefault();
focusNext();
break;
}
case 'ArrowUp': {
e.preventDefault();
focusPrevious();
break;
}
default:
}
}, [expanded]);
useEffect(() => () => {
// Call onClose callback when unmounting and open
if (expanded && onClose) {
onClose();
}
}, []);
const wrappedChildren = React.Children.map(children, (child) => {
if (child.type === MenuTriggerType) {
return React.cloneElement(child, {
onClick: onTriggerClick,
'aria-haspopup': 'menu',
'aria-expanded': expanded,
});
}
return (
<CSSTransition
in={expanded}
timeout={transitionTimeout}
classNames={transitionClassName}
unmountOnExit
>
{child}
</CSSTransition>
);
});
const rootClassName = expanded ? 'menu expanded' : 'menu';
return React.createElement(tag, {
className: `${rootClassName} ${className}`,
ref: menu,
onKeyDown,
onMouseEnter,
onMouseLeave,
...attributes,
}, wrappedChildren);
}
Menu.propTypes = {
tag: PropTypes.string,
onClose: PropTypes.func,
onOpen: PropTypes.func,
closeOnDocumentClick: PropTypes.bool,
respondToPointerEvents: PropTypes.bool,
className: PropTypes.string,
transitionTimeout: PropTypes.number,
transitionClassName: PropTypes.string,
children: PropTypes.arrayOf(PropTypes.node).isRequired,
};
Menu.defaultProps = {
tag: 'div',
className: null,
onClose: null,
onOpen: null,
respondToPointerEvents: false,
closeOnDocumentClick: true,
transitionTimeout: 250,
transitionClassName: 'menu-content',
};

View File

@@ -1,33 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { getConfig } from '@edx/frontend-platform';
export default function NavTab(props) {
const {
isActive, url, title, ...attrs
} = props;
const className = classNames(
'nav-item nav-link',
{ active: isActive },
attrs.className,
);
// TODO: We probably don't want to blindly add LMS_BASE_URL here. I think it's more likely
// that the course metadata API should provide us fully qualified URLs.
return <a {...attrs} className={className} href={`${getConfig().LMS_BASE_URL}${url}`}>{title}</a>;
}
NavTab.propTypes = {
className: PropTypes.string,
isActive: PropTypes.bool,
title: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
};
NavTab.defaultProps = {
className: undefined,
isActive: false,
};

View File

@@ -1,160 +0,0 @@
/* eslint-disable no-plusplus */
import React, { useEffect, useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { history } from '@edx/frontend-platform';
import messages from '../messages';
import PageLoading from '../../PageLoading';
import Sequence from '../sequence/Sequence';
import AlertList from '../../user-messages/AlertList';
import { fetchSequenceMetadata, checkBlockCompletion, saveSequencePosition } from '../../data/course-blocks';
function SequenceContainer(props) {
const {
courseUsageKey,
courseId,
sequenceId,
unitId,
intl,
onNext,
onPrevious,
fetchState,
displayName,
showCompletion,
isTimeLimited,
savePosition,
bannerText,
gatedContent,
position,
items,
lmsWebUrl,
} = props;
const loaded = fetchState === 'loaded';
const unitIds = useMemo(() => items.map(({ id }) => id), [items]);
useEffect(() => {
props.fetchSequenceMetadata(sequenceId);
}, [sequenceId]);
useEffect(() => {
if (savePosition) {
const activeUnitIndex = unitIds.indexOf(unitId);
props.saveSequencePosition(courseUsageKey, sequenceId, activeUnitIndex);
}
}, [unitId]);
useEffect(() => {
if (loaded && !unitId) {
// The position may be null, in which case we'll just assume 0.
const unitIndex = position || 0;
const nextUnitId = unitIds[unitIndex];
history.push(`/course/${courseUsageKey}/${sequenceId}/${nextUnitId}`);
}
}, [loaded, unitId]);
const handleUnitNavigation = useCallback((nextUnitId) => {
props.checkBlockCompletion(courseUsageKey, sequenceId, unitId);
history.push(`/course/${courseUsageKey}/${sequenceId}/${nextUnitId}`);
}, [courseUsageKey, sequenceId]);
// Exam redirect
useEffect(() => {
if (isTimeLimited) {
global.location.href = lmsWebUrl;
}
}, [isTimeLimited]);
const isLoading = !loaded || !unitId || isTimeLimited;
return (
<>
<AlertList topic="sequence" />
<div className="course-content-container">
{isLoading ? (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
/>
) : (
<Sequence
id={sequenceId}
courseUsageKey={courseUsageKey}
courseId={courseId}
unitIds={unitIds}
displayName={displayName}
activeUnitId={unitId}
showCompletion={showCompletion}
isTimeLimited={isTimeLimited}
isGated={gatedContent.gated}
savePosition={savePosition}
bannerText={bannerText}
onNext={onNext}
onPrevious={onPrevious}
onNavigateUnit={handleUnitNavigation}
prerequisite={{
id: gatedContent.prereqId,
name: gatedContent.gatedSectionName,
}}
/>
)}
</div>
</>
);
}
SequenceContainer.propTypes = {
onNext: PropTypes.func.isRequired,
onPrevious: PropTypes.func.isRequired,
courseUsageKey: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string.isRequired,
unitId: PropTypes.string,
intl: intlShape.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
})),
gatedContent: PropTypes.shape({
gated: PropTypes.bool,
gatedSectionName: PropTypes.string,
prereqId: PropTypes.string,
}),
checkBlockCompletion: PropTypes.func.isRequired,
fetchSequenceMetadata: PropTypes.func.isRequired,
saveSequencePosition: PropTypes.func.isRequired,
savePosition: PropTypes.bool,
lmsWebUrl: PropTypes.string,
position: PropTypes.number,
fetchState: PropTypes.string,
displayName: PropTypes.string,
showCompletion: PropTypes.bool,
isTimeLimited: PropTypes.bool,
bannerText: PropTypes.string,
};
SequenceContainer.defaultProps = {
unitId: undefined,
gatedContent: undefined,
showCompletion: false,
lmsWebUrl: undefined,
position: undefined,
fetchState: undefined,
displayName: undefined,
isTimeLimited: undefined,
bannerText: undefined,
savePosition: undefined,
items: [],
};
export default connect(
(state, props) => ({
...state.courseBlocks.blocks[props.sequenceId],
}),
{
fetchSequenceMetadata,
checkBlockCompletion,
saveSequencePosition,
},
)(injectIntl(SequenceContainer));

View File

@@ -1,9 +1,11 @@
import React from 'react';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { StatefulButton } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { useDispatch } from 'react-redux';
import BookmarkOutlineIcon from './BookmarkOutlineIcon';
import BookmarkFilledIcon from './BookmarkFilledIcon';
import { removeBookmark, addBookmark } from './data/thunks';
const addBookmarkLabel = (
<FormattedMessage
@@ -21,14 +23,25 @@ const hasBookmarkLabel = (
/>
);
export default function BookmarkButton({ onClick, isBookmarked, isProcessing }) {
export default function BookmarkButton({
isBookmarked, isProcessing, unitId,
}) {
const bookmarkState = isBookmarked ? 'bookmarked' : 'default';
const state = isProcessing ? `${bookmarkState}Processing` : bookmarkState;
const dispatch = useDispatch();
const toggleBookmark = useCallback(() => {
if (isBookmarked) {
dispatch(removeBookmark(unitId));
} else {
dispatch(addBookmark(unitId));
}
}, [isBookmarked, unitId]);
return (
<StatefulButton
className="btn-link px-1 ml-n1 btn-sm"
onClick={onClick}
onClick={toggleBookmark}
state={state}
disabledStates={['defaultProcessing', 'bookmarkedProcessing']}
labels={{
@@ -48,7 +61,11 @@ export default function BookmarkButton({ onClick, isBookmarked, isProcessing })
}
BookmarkButton.propTypes = {
onClick: PropTypes.func.isRequired,
isBookmarked: PropTypes.bool.isRequired,
unitId: PropTypes.string.isRequired,
isBookmarked: PropTypes.bool,
isProcessing: PropTypes.bool.isRequired,
};
BookmarkButton.defaultProps = {
isBookmarked: false,
};

View File

@@ -0,0 +1,13 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
const bookmarksBaseUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
export async function createBookmark(usageId) {
return getAuthenticatedHttpClient().post(bookmarksBaseUrl, { usage_id: usageId });
}
export async function deleteBookmark(usageId) {
const { username } = getAuthenticatedUser();
return getAuthenticatedHttpClient().delete(`${bookmarksBaseUrl}${username},${usageId}/`);
}

View File

@@ -0,0 +1,78 @@
import { logError } from '@edx/frontend-platform/logging';
import {
createBookmark,
deleteBookmark,
} from './api';
import { updateModel } from '../../../../model-store';
export function addBookmark(unitId) {
return async (dispatch) => {
// Optimistically update the bookmarked flag.
dispatch(updateModel({
modelType: 'units',
model: {
id: unitId,
bookmarked: true,
bookmarkedUpdateState: 'loading',
},
}));
try {
await createBookmark(unitId);
dispatch(updateModel({
modelType: 'units',
model: {
id: unitId,
bookmarked: true,
bookmarkedUpdateState: 'loaded',
},
}));
} catch (error) {
logError(error);
dispatch(updateModel({
modelType: 'units',
model: {
id: unitId,
bookmarked: false,
bookmarkedUpdateState: 'failed',
},
}));
}
};
}
export function removeBookmark(unitId) {
return async (dispatch) => {
// Optimistically update the bookmarked flag.
dispatch(updateModel({
modelType: 'units',
model: {
id: unitId,
bookmarked: false,
bookmarkedUpdateState: 'loading',
},
}));
try {
await deleteBookmark(unitId);
dispatch(updateModel({
modelType: 'units',
model: {
id: unitId,
bookmarked: false,
bookmarkedUpdateState: 'loaded',
},
}));
} catch (error) {
logError(error);
dispatch(updateModel({
modelType: 'units',
model: {
id: unitId,
bookmarked: true,
bookmarkedUpdateState: 'failed',
},
}));
}
};
}

View File

@@ -0,0 +1,3 @@
export { default as BookmarkButton } from './BookmarkButton';
export { default as BookmarkFilledIcon } from './BookmarkFilledIcon';
export { default as BookmarkOutlineIcon } from './BookmarkFilledIcon';

View File

@@ -32,128 +32,152 @@ export default class CourseSock extends Component {
</button>
</div>
{this.state.showUpsell && (
<div className="d-flex justify-content-around">
<div className="mt-3">
<h2 className="font-weight-lighter">
<>
<h2 className="mt-3 mb-4">
<FormattedMessage
id="coursesock.upsell.verifiedcert"
defaultMessage="edX Verified Certificate"
/>
</h2>
<h3>
<FormattedMessage
id="coursesock.upsell.why"
defaultMessage="Why upgrade?"
/>
</h3>
<ul>
<li><FormattedMessage
id="coursesock.upsell.reason1"
defaultMessage="Official proof of completion"
/>
</li>
<li><FormattedMessage
id="coursesock.upsell.reason2"
defaultMessage="Easily shareable certificate"
/>
</li>
<li><FormattedMessage
id="coursesock.upsell.reason3"
defaultMessage="Proven motivator to complete the course"
/>
</li>
<li><FormattedMessage
id="coursesock.upsell.reason4"
defaultMessage="Certificate purchases help edX continue to offer free courses"
/>
</li>
</ul>
<h3><FormattedMessage
id="coursesock.upsell.howtitle"
defaultMessage="How it works"
/>
</h3>
<ul>
<li><FormattedMessage
id="coursesock.upsell.how1"
defaultMessage="Pay the Verified Certificate upgrade fee"
/>
</li>
<li><FormattedMessage
id="coursesock.upsell.how2"
defaultMessage="Verify your identity with a webcam and government-issued ID"
/>
</li>
<li><FormattedMessage
id="coursesock.upsell.how3"
defaultMessage="Study hard and pass the course"
/>
</li>
<li><FormattedMessage
id="coursesock.upsell.how4"
defaultMessage="Share your certificate with friends, employers, and others"
/>
</li>
</ul>
<h3><FormattedMessage
id="coursesock.upsell.storytitle"
defaultMessage="edX Learner Stories"
/>
</h3>
<div className="d-flex align-items-center my-4">
<img style={{ maxWidth: '4rem' }} alt="Christina Fong" src={LearnerQuote1} />
<div className="w-50 px-4">
<FormattedMessage
id="coursesock.upsell.story1"
defaultMessage="My certificate has helped me showcase my knowledge on my
resume - I feel like this certificate could really help me land
my dream job!"
/>
<br />
<strong>&mdash; <FormattedMessage
id="coursesock.upsell.learner"
description="Name of learner"
defaultMessage="{ name }, edX Learner"
values={{ name: 'Christina Fong' }}
/>
</strong>
<div className="row flex-row-reverse">
<div className="col-md-4 col-lg-6 d-flex flex-column">
<div>
<img alt="Example Certificate" src={VerifiedCert} className="d-block img-thumbnail mb-3 ml-md-auto" />
</div>
<div className="position-relative flex-grow-1 d-flex flex-column justify-content-end align-items-md-end">
<div style={{ position: 'sticky', bottom: '4rem' }}>
<a
href={this.verifiedMode.upgradeUrl}
className="btn btn-success btn-lg btn-upgrade focusable mb-3"
data-creative="original_sock"
data-position="sock"
>
<FormattedMessage
id="coursesock.upsell.upgrade"
defaultMessage="Upgrade ({symbol}{price} {currency})"
values={{
symbol: this.verifiedMode.currencySymbol,
price: this.verifiedMode.price,
currency: this.verifiedMode.currency,
}}
/>
</a>
</div>
</div>
</div>
<div className="col-md-8 col-lg-6">
<h3 className="h5">
<FormattedMessage
id="coursesock.upsell.why"
defaultMessage="Why upgrade?"
/>
</h3>
<ul>
<li>
<FormattedMessage
id="coursesock.upsell.reason1"
defaultMessage="Official proof of completion"
/>
</li>
<li>
<FormattedMessage
id="coursesock.upsell.reason2"
defaultMessage="Easily shareable certificate"
/>
</li>
<li>
<FormattedMessage
id="coursesock.upsell.reason3"
defaultMessage="Proven motivator to complete the course"
/>
</li>
<li>
<FormattedMessage
id="coursesock.upsell.reason4"
defaultMessage="Certificate purchases help edX continue to offer free courses"
/>
</li>
</ul>
<h3 className="h5">
<FormattedMessage
id="coursesock.upsell.howtitle"
defaultMessage="How it works"
/>
</h3>
<ul>
<li>
<FormattedMessage
id="coursesock.upsell.how1"
defaultMessage="Pay the Verified Certificate upgrade fee"
/>
</li>
<li>
<FormattedMessage
id="coursesock.upsell.how2"
defaultMessage="Verify your identity with a webcam and government-issued ID"
/>
</li>
<li>
<FormattedMessage
id="coursesock.upsell.how3"
defaultMessage="Study hard and pass the course"
/>
</li>
<li>
<FormattedMessage
id="coursesock.upsell.how4"
defaultMessage="Share your certificate with friends, employers, and others"
/>
</li>
</ul>
<h3 className="h5">
<FormattedMessage
id="coursesock.upsell.storytitle"
defaultMessage="edX Learner Stories"
/>
</h3>
<div className="media my-3">
<img className="mr-3" style={{ maxWidth: '4rem' }} alt="Christina Fong" src={LearnerQuote1} />
<div className="media-body">
<FormattedMessage
id="coursesock.upsell.story1"
defaultMessage="My certificate has helped me showcase my knowledge on my
resume - I feel like this certificate could really help me land
my dream job!"
/>
<p className="font-weight-bold">
&mdash; <FormattedMessage
id="coursesock.upsell.learner"
description="Name of learner"
defaultMessage="{ name }, edX Learner"
values={{ name: 'Christina Fong' }}
/>
</p>
</div>
</div>
<div className="media my-3">
<img className="mr-3" style={{ maxWidth: '4rem' }} alt="Chery Troell" src={LearnerQuote2} />
<div className="media-body">
<FormattedMessage
id="coursesock.upsell.story2"
defaultMessage="I wanted to include a verified certificate on my resume and my profile to
illustrate that I am working towards this goal I have and that I have
achieved something while I was unemployed."
/>
<p className="font-weight-bold">
&mdash; <FormattedMessage
id="coursesock.upsell.learner"
description="Name of learner"
defaultMessage="{ name }, edX Learner"
values={{ name: 'Cheryl Troell' }}
/>
</p>
</div>
</div>
</div>
</div>
<div className="d-flex align-items-center my-2">
<img style={{ maxWidth: '4rem' }} alt="Chery Troell" src={LearnerQuote2} />
<div className="w-50 px-4">
<FormattedMessage
id="coursesock.upsell.story2"
defaultMessage="I wanted to include a verified certificate on my resume and my profile to
illustrate that I am working towards this goal I have and that I have
achieved something while I was unemployed."
/>
<br />
<strong>&mdash; <FormattedMessage
id="coursesock.upsell.learner"
description="Name of learner"
defaultMessage="{ name }, edX Learner"
values={{ name: 'Cheryl Troell' }}
/>
</strong>
</div>
</div>
</div>
<div className="d-flex flex-column justify-content-between">
<img alt="Example Certificate" src={VerifiedCert} />
<a href={this.verifiedMode.upgradeUrl} className="btn btn-success btn-lg btn-upgrade focusable" data-creative="original_sock" data-position="sock">
<FormattedMessage
id="coursesock.upsell.upgrade"
defaultMessage="Upgrade ({symbol}{price} {currency})"
values={{
symbol: this.verifiedMode.currencySymbol,
price: this.verifiedMode.price,
currency: this.verifiedMode.currency,
}}
/>
</a>
</div>
</div>
</>
)}
</div>
);

View File

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

View File

@@ -0,0 +1,184 @@
/* eslint-disable no-use-before-define */
import React, {
useEffect, useContext, useState,
} from 'react';
import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import PageLoading from '../../../PageLoading';
import { UserMessagesContext, ALERT_TYPES } from '../../../user-messages';
import { useModel } from '../../../model-store';
import messages from './messages';
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
import SequenceContent from './SequenceContent';
function Sequence({
unitId,
sequenceId,
courseId,
unitNavigationHandler,
nextSequenceHandler,
previousSequenceHandler,
intl,
}) {
const sequence = useModel('sequences', sequenceId);
const unit = useModel('units', unitId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const handleNext = () => {
const nextIndex = sequence.unitIds.indexOf(unitId) + 1;
if (nextIndex < sequence.unitIds.length) {
const newUnitId = sequence.unitIds[nextIndex];
handleNavigate(newUnitId);
} else {
nextSequenceHandler();
}
};
const handlePrevious = () => {
const previousIndex = sequence.unitIds.indexOf(unitId) - 1;
if (previousIndex >= 0) {
const newUnitId = sequence.unitIds[previousIndex];
handleNavigate(newUnitId);
} else {
previousSequenceHandler();
}
};
const handleNavigate = (destinationUnitId) => {
unitNavigationHandler(destinationUnitId);
};
const logEvent = (eventName, widgetPlacement, targetUnitId) => {
// Note: tabs are tracked with a 1-indexed position
// as opposed to a 0-index used throughout this MFE
const currentIndex = sequence.unitIds.length > 0 ? sequence.unitIds.indexOf(unitId) : 0;
const payload = {
current_tab: currentIndex + 1,
id: unitId,
tab_count: sequence.unitIds.length,
widget_placement: widgetPlacement,
};
if (targetUnitId) {
const targetIndex = sequence.unitIds.indexOf(targetUnitId);
payload.target_tab = targetIndex + 1;
}
sendTrackEvent(eventName, payload);
};
const { add, remove } = useContext(UserMessagesContext);
useEffect(() => {
let id = null;
if (sequenceStatus === 'loaded') {
if (sequence.bannerText) {
id = add({
code: null,
dismissible: false,
text: sequence.bannerText,
type: ALERT_TYPES.INFO,
topic: 'sequence',
});
}
}
return () => {
if (id) {
remove(id);
}
};
}, [sequenceStatus, sequence]);
const [unitHasLoaded, setUnitHasLoaded] = useState(false);
const handleUnitLoaded = () => {
setUnitHasLoaded(true);
};
useEffect(() => {
if (unit) {
setUnitHasLoaded(false);
}
}, [unit]);
if (sequenceStatus === 'loading') {
return (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
/>
);
}
const gated = sequence.gatedContent !== undefined && sequence.gatedContent.gated;
if (sequenceStatus === 'loaded') {
return (
<div className="sequence-container">
<div className="sequence">
<SequenceNavigation
sequenceId={sequenceId}
unitId={unitId}
className="mb-4"
nextSequenceHandler={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'top');
handleNext();
}}
onNavigate={(destinationUnitId) => {
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
handleNavigate(destinationUnitId);
}}
previousSequenceHandler={() => {
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
handlePrevious();
}}
/>
<div className="unit-container flex-grow-1">
<SequenceContent
courseId={courseId}
gated={gated}
sequenceId={sequenceId}
unitId={unitId}
unitLoadedHandler={handleUnitLoaded}
/>
{unitHasLoaded && (
<UnitNavigation
sequenceId={sequenceId}
unitId={unitId}
onClickPrevious={() => {
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
handlePrevious();
}}
onClickNext={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
handleNext();
}}
/>
)}
</div>
</div>
</div>
);
}
// sequence status 'failed' and any other unexpected sequence status.
return (
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
{intl.formatMessage(messages['learn.course.load.failure'])}
</p>
);
}
Sequence.propTypes = {
unitId: PropTypes.string,
sequenceId: PropTypes.string,
courseId: PropTypes.string.isRequired,
unitNavigationHandler: PropTypes.func.isRequired,
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
Sequence.defaultProps = {
sequenceId: null,
unitId: null,
};
export default injectIntl(Sequence);

View File

@@ -0,0 +1,72 @@
import React, { Suspense, useEffect } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PageLoading from '../../../PageLoading';
import { useModel } from '../../../model-store';
import messages from './messages';
import Unit from './Unit';
const ContentLock = React.lazy(() => import('./content-lock'));
function SequenceContent({
gated, intl, courseId, sequenceId, unitId, unitLoadedHandler,
}) {
const sequence = useModel('sequences', sequenceId);
// Go back to the top of the page whenever the unit or sequence changes.
useEffect(() => {
global.scrollTo(0, 0);
}, [sequenceId, unitId]);
if (gated) {
return (
<Suspense
fallback={(
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
/>
)}
>
<ContentLock
courseId={courseId}
sequenceTitle={sequence.title}
prereqSectionName={sequence.gatedContent.gatedSectionName}
prereqId={sequence.gatedContent.prereqId}
/>
</Suspense>
);
}
if (unitId === null) {
return (
<div>
{intl.formatMessage(messages['learn.sequence.no.content'])}
</div>
);
}
return (
<Unit
courseId={courseId}
key={unitId}
id={unitId}
onLoaded={unitLoadedHandler}
/>
);
}
SequenceContent.propTypes = {
gated: PropTypes.bool.isRequired,
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string.isRequired,
unitId: PropTypes.string,
unitLoadedHandler: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
SequenceContent.defaultProps = {
unitId: null,
};
export default injectIntl(SequenceContent);

View File

@@ -0,0 +1,151 @@
import React, {
Suspense,
useEffect,
useRef,
useState,
useLayoutEffect,
} from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import BookmarkButton from '../bookmark/BookmarkButton';
import { useModel } from '../../../model-store';
import PageLoading from '../../../PageLoading';
const LockPaywall = React.lazy(() => import('./lock-paywall'));
/**
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
* useEffect hooks until the user interacts with the page again. This is particularly confusing
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
* state.
*
* We were able to solve this error by using a layout effect to update some component state, which
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
* a joke) one here so it wouldn't be accidentally removed elsewhere.
*
* If we remove this hook when one of these happens:
* 1. React figures out that there's an issue here and fixes a bug.
* 2. We cease to use an iframe for unit rendering.
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
* 4. We stop supporting Firefox.
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
* Firefox/React for review, and they kindly help us figure out what in the world is happening
* so we can fix it.
*
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
*/
function useLoadBearingHook(id) {
const setValue = useState(0)[1];
useLayoutEffect(() => {
setValue(currentValue => currentValue + 1);
}, [id]);
}
function Unit({
courseId,
onLoaded,
id,
intl,
}) {
const iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0`;
const [iframeHeight, setIframeHeight] = useState(0);
const [hasLoaded, setHasLoaded] = useState(false);
const unit = useModel('units', id);
const course = useModel('courses', courseId);
const {
contentTypeGatingEnabled,
enrollmentMode,
} = course;
// Do not remove this hook. See function description.
useLoadBearingHook(id);
// We use this ref so that we can hold a reference to the currently active event listener.
const messageEventListenerRef = useRef(null);
useEffect(() => {
function receiveMessage(event) {
const { type, payload } = event.data;
if (type === 'plugin.resize') {
setIframeHeight(payload.height);
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
setHasLoaded(true);
if (onLoaded) {
onLoaded();
}
}
}
}
// If we currently have an event listener, remove it.
if (messageEventListenerRef.current !== null) {
global.removeEventListener('message', messageEventListenerRef.current);
messageEventListenerRef.current = null;
}
// Now add our new receiveMessage handler as the event listener.
global.addEventListener('message', receiveMessage);
// And then save it to our ref for next time.
messageEventListenerRef.current = receiveMessage;
// When the component finally unmounts, use the ref to remove the correct handler.
return () => global.removeEventListener('message', messageEventListenerRef.current);
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
return (
<div className="unit">
<h2 className="mb-0 h4">{unit.title}</h2>
<BookmarkButton
unitId={unit.id}
isBookmarked={unit.bookmarked}
isProcessing={unit.bookmarkedUpdateState === 'loading'}
/>
{ contentTypeGatingEnabled && unit.graded && enrollmentMode === 'audit' && (
<Suspense
fallback={(
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
/>
)}
>
<LockPaywall
courseId={courseId}
/>
</Suspense>
)}
{!hasLoaded && (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
/>
)}
<div className="unit-iframe-wrapper">
<iframe
id="unit-iframe"
title={unit.title}
src={iframeUrl}
allowFullScreen
height={iframeHeight}
scrolling="no"
referrerPolicy="origin"
/>
</div>
</div>
);
}
Unit.propTypes = {
courseId: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
intl: intlShape.isRequired,
onLoaded: PropTypes.func,
};
Unit.defaultProps = {
onLoaded: undefined,
};
export default injectIntl(Unit);

View File

@@ -9,10 +9,10 @@ import { Button } from '@edx/paragon';
import messages from './messages';
function ContentLock({
intl, courseUsageKey, prereqSectionName, prereqId, sectionName,
intl, courseId, prereqSectionName, prereqId, sequenceTitle,
}) {
const handleClick = useCallback(() => {
history.push(`/course/${courseUsageKey}/${prereqId}`);
history.push(`/course/${courseId}/${prereqId}`);
});
return (
@@ -20,7 +20,7 @@ function ContentLock({
<h3>
<FontAwesomeIcon icon={faLock} />
{' '}
{sectionName}
{sequenceTitle}
</h3>
<h4>{intl.formatMessage(messages['learn.contentLock.content.locked'])}</h4>
<p>
@@ -36,9 +36,9 @@ function ContentLock({
}
ContentLock.propTypes = {
intl: intlShape.isRequired,
courseUsageKey: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
prereqSectionName: PropTypes.string.isRequired,
prereqId: PropTypes.string.isRequired,
sectionName: PropTypes.string.isRequired,
sequenceTitle: PropTypes.string.isRequired,
};
export default injectIntl(ContentLock);

View File

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

View File

@@ -0,0 +1,61 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import VerifiedCert from './assets/edx-verified-mini-cert.png';
import { useModel } from '../../../../model-store';
function LockPaywall({
intl,
courseId,
}) {
const course = useModel('courses', courseId);
const {
verifiedMode,
} = course;
if (!verifiedMode) {
return null;
}
const {
currencySymbol,
price,
upgradeUrl,
} = verifiedMode;
return (
<div className="border border-gray rounded d-flex justify-content-between mt-2 p-3">
<div>
<h4 className="font-weight-bold mb-2">
<FontAwesomeIcon icon={faLock} className="text-black mr-2 ml-1" style={{ fontSize: '2rem' }} />
<span>{intl.formatMessage(messages['learn.lockPaywall.title'])}</span>
</h4>
<p className="mb-0">
<span>{intl.formatMessage(messages['learn.lockPaywall.content'])}</span>
&nbsp;
<a href={upgradeUrl}>
{intl.formatMessage(messages['learn.lockPaywall.upgrade.link'], {
currencySymbol,
price,
})}
</a>
</p>
</div>
<div>
<img
alt={intl.formatMessage(messages['learn.lockPaywall.example.alt'])}
src={VerifiedCert}
className="border-0"
style={{ height: '70px' }}
/>
</div>
</div>
);
}
LockPaywall.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default injectIntl(LockPaywall);

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

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

View File

@@ -0,0 +1,26 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'learn.lockPaywall.title': {
id: 'learn.lockPaywall.title',
defaultMessage: 'Verified Track Access',
description: 'Heading for message shown to indicate that a piece of content is unavailable to audit track users.',
},
'learn.lockPaywall.content': {
id: 'learn.lockPaywall.content',
defaultMessage: 'Graded assessments are available to Verified Track learners.',
description: 'Message shown to indicate that a piece of content is unavailable to audit track users.',
},
'learn.lockPaywall.upgrade.link': {
id: 'learn.lockPaywall.upgrade.link',
defaultMessage: 'Upgrade to unlock ({currencySymbol}{price})',
description: 'A link users can click that navigates their browser to the upgrade payment page.',
},
'learn.lockPaywall.example.alt': {
id: 'learn.lockPaywall.example.alt',
defaultMessage: 'Example Certificate',
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
},
});
export default messages;

View File

@@ -0,0 +1,26 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'learn.loading.content.lock': {
id: 'learn.loading.content.lock',
defaultMessage: 'Loading locked content messaging...',
description: 'Message shown when an interface about locked content is being loaded',
},
'learn.loading.learning.sequence': {
id: 'learn.loading.learning.sequence',
defaultMessage: 'Loading learning sequence...',
description: 'Message when learning sequence is being loaded',
},
'learn.course.load.failure': {
id: 'learn.course.load.failure',
defaultMessage: 'There was an error loading this course.',
description: 'Message when a course fails to load',
},
'learn.sequence.no.content': {
id: 'learn.sequence.no.content',
defaultMessage: 'There is no content here.',
description: 'Message shown when there is no content to show a user inside a learning sequence.',
},
});
export default messages;

View File

@@ -0,0 +1,84 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import classNames from 'classnames';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import UnitButton from './UnitButton';
import SequenceNavigationTabs from './SequenceNavigationTabs';
import { useSequenceNavigationMetadata } from './hooks';
import { useModel } from '../../../../model-store';
export default function SequenceNavigation({
unitId,
sequenceId,
className,
onNavigate,
nextSequenceHandler,
previousSequenceHandler,
}) {
const sequence = useModel('sequences', sequenceId);
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
const isLocked = sequence.gatedContent !== undefined && sequence.gatedContent.gated;
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const renderUnitButtons = () => {
if (isLocked) {
return (
<UnitButton unitId={unitId} title="" contentType="lock" isActive onClick={() => {}} />
);
}
if (sequence.unitIds.length === 0 || unitId === null) {
return (
<div style={{ flexBasis: '100%', minWidth: 0, borderBottom: 'solid 1px #EAEAEA' }} />
);
}
return (
<SequenceNavigationTabs
unitIds={sequence.unitIds}
unitId={unitId}
showCompletion={sequence.showCompletion}
onNavigate={onNavigate}
/>
);
};
return sequenceStatus === 'loaded' && (
<nav className={classNames('sequence-navigation', className)}>
<Button className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit}>
<FontAwesomeIcon icon={faChevronLeft} className="mr-2" size="sm" />
<FormattedMessage
defaultMessage="Previous"
id="learn.sequence.navigation.previous.button"
description="The Previous button in the sequence nav"
/>
</Button>
{renderUnitButtons()}
<Button className="next-btn" onClick={nextSequenceHandler} disabled={isLastUnit}>
<FormattedMessage
defaultMessage="Next"
id="learn.sequence.navigation.next.button"
description="The Next button in the sequence nav"
/>
<FontAwesomeIcon icon={faChevronRight} className="ml-2" size="sm" />
</Button>
</nav>
);
}
SequenceNavigation.propTypes = {
sequenceId: PropTypes.string.isRequired,
unitId: PropTypes.string,
className: PropTypes.string,
onNavigate: PropTypes.func.isRequired,
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
};
SequenceNavigation.defaultProps = {
className: null,
unitId: null,
};

View File

@@ -0,0 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Dropdown } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import UnitButton from './UnitButton';
export default function SequenceNavigationDropdown({
unitId,
onNavigate,
showCompletion,
unitIds,
}) {
return (
<Dropdown className="sequence-navigation-dropdown">
<Dropdown.Button className="dropdown-button font-weight-normal w-100 border-right-0">
<FormattedMessage
defaultMessage="{current} of {total}"
description="The title of the mobile menu for sequence navigation of units"
id="learn.course.sequence.navigation.mobile.menu"
values={{
current: unitIds.indexOf(unitId) + 1,
total: unitIds.length,
}}
/>
</Dropdown.Button>
<Dropdown.Menu className="w-100">
{unitIds.map(buttonUnitId => (
<UnitButton
className="w-100"
isActive={unitId === buttonUnitId}
key={buttonUnitId}
onClick={onNavigate}
showCompletion={showCompletion}
showTitle
unitId={buttonUnitId}
/>
))}
</Dropdown.Menu>
</Dropdown>
);
}
SequenceNavigationDropdown.propTypes = {
unitId: PropTypes.string.isRequired,
onNavigate: PropTypes.func.isRequired,
showCompletion: PropTypes.bool.isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
};

View File

@@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import UnitButton from './UnitButton';
import SequenceNavigationDropdown from './SequenceNavigationDropdown';
import useIndexOfLastVisibleChild from '../../../../tabs/useIndexOfLastVisibleChild';
export default function SequenceNavigationTabs({
unitIds, unitId, showCompletion, onNavigate,
}) {
const [
indexOfLastVisibleChild,
containerRef,
invisibleStyle,
] = useIndexOfLastVisibleChild();
const shouldDisplayDropdown = indexOfLastVisibleChild === -1;
return (
<div style={{ flexBasis: '100%', minWidth: 0 }}>
<div className="sequence-navigation-tabs-container" ref={containerRef}>
<div
className="sequence-navigation-tabs d-flex flex-grow-1"
style={shouldDisplayDropdown ? invisibleStyle : null}
>
{unitIds.map(buttonUnitId => (
<UnitButton
key={buttonUnitId}
unitId={buttonUnitId}
isActive={unitId === buttonUnitId}
showCompletion={showCompletion}
onClick={onNavigate}
/>
))}
</div>
</div>
{shouldDisplayDropdown && (
<SequenceNavigationDropdown
unitId={unitId}
onNavigate={onNavigate}
showCompletion={showCompletion}
unitIds={unitIds}
/>
)}
</div>
);
}
SequenceNavigationTabs.propTypes = {
unitId: PropTypes.string.isRequired,
onNavigate: PropTypes.func.isRequired,
showCompletion: PropTypes.bool.isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
};

View File

@@ -6,17 +6,19 @@ import { Button } from '@edx/paragon';
import UnitIcon from './UnitIcon';
import CompleteIcon from './CompleteIcon';
import BookmarkFilledIcon from './bookmark/BookmarkFilledIcon';
import BookmarkFilledIcon from '../../bookmark/BookmarkFilledIcon';
function UnitButton({
onClick,
displayName,
title,
contentType,
isActive,
bookmarked,
complete,
showCompletion,
unitId,
className,
showTitle,
}) {
const handleClick = useCallback(() => {
onClick(unitId);
@@ -27,11 +29,12 @@ function UnitButton({
className={classNames({
active: isActive,
complete: showCompletion && complete,
})}
}, className)}
onClick={handleClick}
title={displayName}
title={title}
>
<UnitIcon type={contentType} />
{showTitle && <span className="unit-title">{title}</span>}
{showCompletion && complete ? <CompleteIcon size="sm" className="text-success ml-2" /> : null}
{bookmarked ? (
<BookmarkFilledIcon
@@ -44,25 +47,29 @@ function UnitButton({
}
UnitButton.propTypes = {
unitId: PropTypes.string.isRequired,
isActive: PropTypes.bool,
bookmarked: PropTypes.bool,
className: PropTypes.string,
complete: PropTypes.bool,
showCompletion: PropTypes.bool,
onClick: PropTypes.func.isRequired,
displayName: PropTypes.string.isRequired,
contentType: PropTypes.string.isRequired,
isActive: PropTypes.bool,
onClick: PropTypes.func.isRequired,
showCompletion: PropTypes.bool,
showTitle: PropTypes.bool,
title: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
};
UnitButton.defaultProps = {
className: undefined,
isActive: false,
bookmarked: false,
complete: false,
showTitle: false,
showCompletion: true,
};
const mapStateToProps = (state, props) => ({
...state.courseBlocks.blocks[props.unitId],
...state.models.units[props.unitId],
});
export default connect(mapStateToProps)(UnitButton);

View File

@@ -0,0 +1,66 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { useSequenceNavigationMetadata } from './hooks';
export default function UnitNavigation(props) {
const {
sequenceId,
unitId,
onClickPrevious,
onClickNext,
} = props;
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
return (
<div className="unit-navigation d-flex">
<Button
className="btn-outline-secondary previous-button mr-2"
disabled={isFirstUnit}
onClick={onClickPrevious}
>
<FontAwesomeIcon icon={faChevronLeft} className="mr-2" size="sm" />
<FormattedMessage
id="learn.sequence.navigation.after.unit.previous"
description="The button to go to the previous unit"
defaultMessage="Previous"
/>
</Button>
{isLastUnit ? (
<div className="m-2">
<span role="img" aria-hidden="true">&#129303;</span> {/* This is a hugging face emoji */}
{' '}
<FormattedMessage
id="learn.end.of.course"
description="Message shown to students in place of a 'Next' button when they're at the end of a course."
defaultMessage="You've reached the end of this course!"
/>
</div>
) : (
<Button
className="btn-outline-primary next-button"
onClick={onClickNext}
disabled={isLastUnit}
>
<FormattedMessage
id="learn.sequence.navigation.after.unit.next"
description="The button to go to the next unit"
defaultMessage="Next"
/>
<FontAwesomeIcon icon={faChevronRight} className="ml-2" size="sm" />
</Button>
)}
</div>
);
}
UnitNavigation.propTypes = {
sequenceId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
onClickPrevious: PropTypes.func.isRequired,
onClickNext: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,24 @@
/* eslint-disable import/prefer-default-export */
import { useSelector } from 'react-redux';
import { useModel } from '../../../../model-store';
import { sequenceIdsSelector } from '../../../data/selectors';
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
const sequenceIds = useSelector(sequenceIdsSelector);
const sequence = useModel('sequences', currentSequenceId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
// If we don't know the sequence and unit yet, then assume no.
if (courseStatus !== 'loaded' || !currentSequenceId || !currentUnitId) {
return { isFirstUnit: false, isLastUnit: false };
}
const isFirstSequence = sequenceIds.indexOf(currentSequenceId) === 0;
const isFirstUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === 0;
const isFirstUnit = isFirstSequence && isFirstUnitInSequence;
const isLastSequence = sequenceIds.indexOf(currentSequenceId) === sequenceIds.length - 1;
const isLastUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === sequence.unitIds.length - 1;
const isLastUnit = isLastSequence && isLastUnitInSequence;
return { isFirstUnit, isLastUnit };
}

View File

@@ -0,0 +1,2 @@
export { default as SequenceNavigation } from './SequenceNavigation';
export { default as UnitNavigation } from './UnitNavigation';

View File

@@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import Calculator from './calculator';
import NotesVisibility from './notes/NotesVisibility';
import './tools.scss';
export default function ContentTools({
course,
}) {
return (
<div className="content-tools">
<div className="d-flex justify-content-end align-items-end m-0">
{course.showCalculator && (
<Calculator />
)}
{course.notes.enabled && (
<NotesVisibility course={course} />
)}
</div>
</div>
);
}
ContentTools.propTypes = {
course: PropTypes.shape({
notes: PropTypes.shape({
enabled: PropTypes.bool,
}),
showCalculator: PropTypes.bool,
}).isRequired,
};

View File

@@ -0,0 +1,7 @@
.calculator {
flex-grow: 1;
.calculator-content {
background-color: #f1f1f1;
box-shadow: 0 -1px 0 0 #ddd;
}
}

View File

@@ -0,0 +1,393 @@
import React, { Component } from 'react';
import { Collapsible } from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCalculator, faQuestionCircle, faTimesCircle, faEquals,
} from '@fortawesome/free-solid-svg-icons';
import messages from './messages';
import './calculator.scss';
class Calculator extends Component {
constructor(props) {
super(props);
this.state = {
equation: '',
result: '',
};
this.handleSubmit = this.handleSubmit.bind(this);
}
async handleSubmit(event) {
event.preventDefault();
event.stopPropagation();
const urlEncoded = new URLSearchParams();
urlEncoded.append('equation', this.state.equation);
const response = await getAuthenticatedHttpClient().get(
`${getConfig().LMS_BASE_URL}/calculate?${urlEncoded.toString()}`,
);
this.setState(() => ({ result: response.data.result }));
}
changeEquation(value) {
this.setState(() => ({ equation: value }));
}
render() {
return (
<Collapsible.Advanced className="calculator">
<div className="text-right">
<Collapsible.Trigger tag="a" className="trigger btn">
<Collapsible.Visible whenOpen>
<FontAwesomeIcon icon={faTimesCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
<Collapsible.Visible whenClosed>
<FontAwesomeIcon icon={faCalculator} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
{this.props.intl.formatMessage(messages['calculator.button.label'])}
</Collapsible.Trigger>
</div>
<Collapsible.Body className="calculator-content pt-4">
<form onSubmit={this.handleSubmit} className="container-fluid form-inline flex-nowrap">
<input
type="text"
placeholder={this.props.intl.formatMessage(messages['calculator.input.field.label'])}
aria-label={this.props.intl.formatMessage(messages['calculator.input.field.label'])}
className="form-control w-100"
onChange={(event) => this.changeEquation(event.target.value)}
/>
<button
className="btn btn-primary mx-3"
aria-label={this.props.intl.formatMessage(messages['calculator.submit.button.label'])}
type="submit"
>
<FontAwesomeIcon icon={faEquals} aria-hidden="true" />
</button>
<input
type="text"
tabIndex="-1"
readOnly
aria-live="polite"
placeholder={this.props.intl.formatMessage(messages['calculator.result.field.placeholder'])}
aria-label={this.props.intl.formatMessage(messages['calculator.result.field.label'])}
className="form-control w-50"
value={this.state.result}
/>
</form>
<Collapsible.Advanced>
<div className="container-fluid">
<Collapsible.Trigger className="btn btn-link btn-sm px-0 d-inline-flex align-items-center">
<Collapsible.Visible whenOpen>
<FontAwesomeIcon icon={faTimesCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
<Collapsible.Visible whenClosed>
<FontAwesomeIcon icon={faQuestionCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
<FormattedMessage
id="calculator.instructions.button.label"
defaultMessage="Calculator Instructions"
/>
</Collapsible.Trigger>
</div>
<Collapsible.Body className="container-fluid pt-3" style={{ maxHeight: '50vh', overflow: 'auto' }}>
<FormattedMessage
tagName="h6"
id="calculator.instructions"
defaultMessage="For detailed information, see {expressions_link} in the {edx_guide}."
values={{
expressions_link: (
<a href="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/completing_assignments/SFD_mathformatting.html#math-formatting">
<FormattedMessage
id="calculator.instructions.expressions.link.title"
defaultMessage="Entering Mathematical and Scientific Expressions"
/>
</a>
),
edx_guide: (
<a href="https://edx-guide-for-students.readthedocs.io/en/latest/index.html">
<FormattedMessage
id="calculator.instructions.edx.guide.link.title"
defaultMessage="edX Guide for Students"
/>
</a>
),
}}
/>
<p>
<strong>
<FormattedMessage
id="calculator.instructions.useful.tips"
defaultMessage="Useful tips:"
/>
</strong>
</p>
<ul>
<li className="hint-item" id="hint-paren">
<FormattedMessage
id="calculator.hint1"
defaultMessage="Use parentheses () to make expressions clear. You can use parentheses inside other parentheses."
/>
</li>
<li className="hint-item" id="hint-spaces">
<FormattedMessage
id="calculator.hint2"
defaultMessage="Do not use spaces in expressions."
/>
</li>
<li className="hint-item" id="hint-howto-constants">
<FormattedMessage
id="calculator.hint3"
defaultMessage="For constants, indicate multiplication explicitly (example: 5*c)."
/>
</li>
<li className="hint-item" id="hint-howto-maffixes">
<FormattedMessage
id="calculator.hint4"
defaultMessage="For affixes, type the number and affix without a space (example: 5c)."
/>
</li>
<li className="hint-item" id="hint-howto-functions">
<FormattedMessage
id="calculator.hint5"
defaultMessage="For functions, type the name of the function, then the expression in parentheses."
/>
</li>
</ul>
<table className="table small">
<thead>
<tr>
<th scope="col">
<FormattedMessage
id="calculator.instruction.table.to.use.heading"
defaultMessage="To Use"
/>
</th>
<th scope="col">
<FormattedMessage
id="calculator.instruction.table.type.heading"
defaultMessage="Type"
/>
</th>
<th scope="col">
<FormattedMessage
id="calculator.instruction.table.examples.heading"
defaultMessage="Examples"
/>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.numbers"
defaultMessage="Numbers"
/>
</th>
<td>
<ul className="list-unstyled m-0">
<li>
<FormattedMessage
id="calculator.instruction.table.to.use.numbers.type1"
defaultMessage="Integers"
/>
</li>
<li>
<FormattedMessage
id="calculator.instruction.table.to.use.numbers.type2"
defaultMessage="Fractions"
/>
</li>
<li>
<FormattedMessage
id="calculator.instruction.table.to.use.numbers.type3"
defaultMessage="Decimals"
/>
</li>
</ul>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>2520</li>
<li>2/3</li>
<li>3.14, .98</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.operators"
defaultMessage="Operators"
/>
</th>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>
{' + - * / '}
<FormattedMessage
id="calculator.instruction.table.to.use.operators.type1"
defaultMessage="(add, subtract, multiply, divide)"
/>
</li>
<li>
{'^ '}
<FormattedMessage
id="calculator.instruction.table.to.use.operators.type2"
defaultMessage="(raise to a power)"
/>
</li>
<li>
{'|| '}
<FormattedMessage
id="calculator.instruction.table.to.use.operators.type3"
defaultMessage="(parallel resistors)"
/>
</li>
</ul>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>x+(2*y)/x-1</li>
<li>x^(n+1)</li>
<li>v_IN+v_OUT</li>
<li>1||2</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.constants"
defaultMessage="Constants"
/>
</th>
<td dir="auto">c, e, g, i, j, k, pi, q, T</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>20*c</li>
<li>418*T</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.affixes"
defaultMessage="Affixes"
/>
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.affixes.type"
defaultMessage="Percent sign (%) and metric affixes ({affixes})"
values={{
affixes: 'd, c, m, u, n, p, k, M, G, T',
}}
/>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>20%</li>
<li>20c</li>
<li>418T</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.basic.functions"
defaultMessage="Basic functions"
/>
</th>
<td dir="auto">abs, exp, fact, factorial, ln, log2, log10, sqrt</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>abs(x+y)</li>
<li>sqrt(x^2-y)</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.trig.functions"
defaultMessage="Trigonometric functions"
/>
</th>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>sin, cos, tan, sec, csc, cot</li>
<li>arcsin, sinh, arcsinh</li>
</ul>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>sin(4x+y)</li>
<li>arccsch(4x+y)</li>
</ul>
</td>
<td dir="auto" />
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation"
defaultMessage="Scientific notation"
/>
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type1"
defaultMessage="{exponentSyntax} and the exponent"
values={{
exponentSyntax: '10^',
}}
/>
</td>
<td dir="auto">10^-9</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type2"
defaultMessage="{notationSyntax} notation"
values={{
notationSyntax: 'e',
}}
/>
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type3"
defaultMessage="{notationSyntax} and the exponent"
values={{
notationSyntax: '1e',
}}
/>
</td>
<td dir="auto">1e-9</td>
</tr>
</tbody>
</table>
</Collapsible.Body>
</Collapsible.Advanced>
</Collapsible.Body>
</Collapsible.Advanced>
);
}
}
Calculator.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(Calculator);

View File

@@ -0,0 +1,31 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'calculator.button.label': {
id: 'calculator.button.label',
defaultMessage: 'Calculator',
description: 'Button label to expand or close the calculator',
},
'calculator.input.field.label': {
id: 'calculator.input.field.label',
defaultMessage: 'Calculator Input',
description: 'label for calculator input',
},
'calculator.submit.button.label': {
id: 'calculator.submit.button.label',
defaultMessage: 'Calculate',
description: 'Submit button label to execute the calculator',
},
'calculator.result.field.label': {
id: 'calculator.result.field.label',
defaultMessage: 'Calculator Result',
description: 'label for calculator result',
},
'calculator.result.field.placeholder': {
id: 'calculator.result.field.placeholder',
defaultMessage: 'Result',
description: 'placeholder for calculator result',
},
});
export default messages;

View File

@@ -0,0 +1,6 @@
import { getConfig } from '@edx/frontend-platform';
export default function toggleNotes() {
const iframe = document.getElementById('unit-iframe');
iframe.contentWindow.postMessage('tools.toggleNotes', getConfig().LMS_BASE_URL);
}

View File

@@ -0,0 +1,65 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import toggleNotes from '../data/api';
import messages from './messages';
class NotesVisibility extends Component {
constructor(props) {
super(props);
this.state = {
visible: props.course.notes.visible,
};
this.visibilityUrl = `${getConfig().LMS_BASE_URL}/courses/${props.course.id}/edxnotes/visibility/`;
}
handleClick = () => {
const data = { visibility: this.state.visible };
getAuthenticatedHttpClient().put(
this.visibilityUrl,
data,
).then(() => {
this.setState((state) => ({ visible: !state.visible }));
toggleNotes();
});
}
render() {
const message = this.state.visible ? 'notes.button.hide' : 'notes.button.show';
return (
<button
className={`trigger btn ${this.state.visible ? 'text-secondary' : 'text-success'} mx-2 `}
role="switch"
type="button"
onClick={this.handleClick}
onKeyDown={this.handleClick}
tabIndex="-1"
aria-checked={this.state.visible ? 'true' : 'false'}
>
<FontAwesomeIcon icon={faPencilAlt} aria-hidden="true" className="mr-2" />
{this.props.intl.formatMessage(messages[message])}
</button>
);
}
}
NotesVisibility.propTypes = {
intl: intlShape.isRequired,
course: PropTypes.shape({
id: PropTypes.string,
notes: PropTypes.shape({
enabled: PropTypes.bool,
visible: PropTypes.bool,
}),
}).isRequired,
};
export default injectIntl(NotesVisibility);

View File

@@ -0,0 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'notes.button.show': {
id: 'notes.button.show',
defaultMessage: 'Show Notes',
description: 'Message for toggling notes visibility',
},
'notes.button.hide': {
id: 'notes.button.hide',
defaultMessage: 'Hide Notes',
description: 'Message for toggling notes visibility',
},
});
export default messages;

View File

@@ -0,0 +1,27 @@
.content-tools {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
.trigger {
cursor: pointer;
display: inline-block;
position: relative;
z-index: 2;
background-color: #f1f1f1;
border: solid 1px #ddd;
border-bottom: none;
border-top-left-radius: .3rem;
border-top-right-radius: .3rem;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
text-overflow: ellipsis;
overflow: hidden;
padding-left: .75rem;
white-space: nowrap;
&:before {
border-radius: .5rem;
}
}
}

View File

@@ -0,0 +1,47 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getSequenceXModuleHandlerUrl = (courseId, sequenceId) => `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler/xmodule_handler`;
export async function getBlockCompletion(courseId, sequenceId, usageKey) {
// Post data sent to this endpoint must be url encoded
// TODO: Remove the need for this to be the case.
// TODO: Ensure this usage of URLSearchParams is working in Internet Explorer
const urlEncoded = new URLSearchParams();
urlEncoded.append('usage_key', usageKey);
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
};
const { data } = await getAuthenticatedHttpClient().post(
`${getSequenceXModuleHandlerUrl(courseId, sequenceId)}/get_completion`,
urlEncoded.toString(),
requestConfig,
);
if (data.complete) {
return true;
}
return false;
}
export async function updateSequencePosition(courseId, sequenceId, position) {
// Post data sent to this endpoint must be url encoded
// TODO: Remove the need for this to be the case.
// TODO: Ensure this usage of URLSearchParams is working in Internet Explorer
const urlEncoded = new URLSearchParams();
// Position is 1-indexed on the server and 0-indexed in this app. Adjust here.
urlEncoded.append('position', position + 1);
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
};
const { data } = await getAuthenticatedHttpClient().post(
`${getSequenceXModuleHandlerUrl(courseId, sequenceId)}/goto_position`,
urlEncoded.toString(),
requestConfig,
);
return data;
}

View File

@@ -0,0 +1,25 @@
/* eslint-disable import/prefer-default-export */
export function sequenceIdsSelector(state) {
if (state.courseware.courseStatus !== 'loaded') {
return [];
}
const { sectionIds = [] } = state.models.courses[state.courseware.courseId];
const sequenceIds = sectionIds
.flatMap(sectionId => state.models.sections[sectionId].sequenceIds);
return sequenceIds;
}
export function firstSequenceIdSelector(state) {
if (state.courseware.courseStatus !== 'loaded') {
return null;
}
const { sectionIds = [] } = state.models.courses[state.courseware.courseId];
if (sectionIds.length === 0) {
return null;
}
return state.models.sections[sectionIds[0]].sequenceIds[0];
}

View File

@@ -0,0 +1,66 @@
import { logError } from '@edx/frontend-platform/logging';
import {
getBlockCompletion,
updateSequencePosition,
} from './api';
import {
updateModel,
} from '../../model-store';
export function checkBlockCompletion(courseId, sequenceId, unitId) {
return async (dispatch, getState) => {
const { models } = getState();
if (models.units[unitId].complete) {
return; // do nothing. Things don't get uncompleted after they are completed.
}
try {
const isComplete = await getBlockCompletion(courseId, sequenceId, unitId);
dispatch(updateModel({
modelType: 'units',
model: {
id: unitId,
complete: isComplete,
},
}));
} catch (error) {
logError(error);
}
};
}
export function saveSequencePosition(courseId, sequenceId, position) {
return async (dispatch, getState) => {
const { models } = getState();
const initialPosition = models.sequences[sequenceId].position;
// Optimistically update the position.
dispatch(updateModel({
modelType: 'sequences',
model: {
id: sequenceId,
position,
},
}));
try {
await updateSequencePosition(courseId, sequenceId, position);
// Update again under the assumption that the above call succeeded, since it doesn't return a
// meaningful response.
dispatch(updateModel({
modelType: 'sequences',
model: {
id: sequenceId,
position,
},
}));
} catch (error) {
logError(error);
dispatch(updateModel({
modelType: 'sequences',
model: {
id: sequenceId,
position: initialPosition,
},
}));
}
};
}

1
src/courseware/index.js Normal file
View File

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

View File

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

View File

@@ -1,174 +0,0 @@
/* eslint-disable no-use-before-define */
import React, {
useEffect, useContext, Suspense, useState,
} from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
import { Button } from '@edx/paragon';
import Unit from './Unit';
import SequenceNavigation from './SequenceNavigation';
import PageLoading from '../../PageLoading';
import messages from './messages';
import UserMessagesContext from '../../user-messages/UserMessagesContext';
const ContentLock = React.lazy(() => import('./content-lock'));
function Sequence({
courseUsageKey,
unitIds,
displayName,
showCompletion,
onNext,
onPrevious,
onNavigateUnit,
isGated,
prerequisite,
activeUnitId,
bannerText,
intl,
}) {
const handleNext = () => {
const nextIndex = unitIds.indexOf(activeUnitId) + 1;
if (nextIndex < unitIds.length) {
const newUnitId = unitIds[nextIndex];
handleNavigate(newUnitId);
} else {
onNext();
}
};
const handlePrevious = () => {
const previousIndex = unitIds.indexOf(activeUnitId) - 1;
if (previousIndex >= 0) {
const newUnitId = unitIds[previousIndex];
handleNavigate(newUnitId);
} else {
onPrevious();
}
};
const handleNavigate = (unitId) => {
onNavigateUnit(unitId);
};
const { add, remove } = useContext(UserMessagesContext);
useEffect(() => {
let id = null;
if (bannerText) {
id = add({
code: null,
dismissible: false,
text: bannerText,
type: 'info',
topic: 'sequence',
});
}
return () => {
if (id) {
remove(id);
}
};
}, [bannerText]);
const [unitHasLoaded, setUnitHasLoaded] = useState(false);
const handleUnitLoaded = () => {
setUnitHasLoaded(true);
};
useEffect(() => {
setUnitHasLoaded(false);
}, [activeUnitId]);
return (
<>
<SequenceNavigation
className="mb-4"
onNext={handleNext}
onNavigate={handleNavigate}
onPrevious={handlePrevious}
unitIds={unitIds}
activeUnitId={activeUnitId}
isLocked={isGated}
showCompletion={showCompletion}
/>
<div className="flex-grow-1">
{isGated && (
<Suspense
fallback={(
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
/>
)}
>
<ContentLock
courseUsageKey={courseUsageKey}
sectionName={displayName}
prereqSectionName={prerequisite.name}
prereqId={prerequisite.id}
/>
</Suspense>
)}
{!isGated && (
<Unit
key={activeUnitId}
id={activeUnitId}
onLoaded={handleUnitLoaded}
/>
)}
</div>
{unitHasLoaded ? (
<div className="unit-content-container below-unit-navigation">
<Button
className="btn-outline-secondary previous-button w-25 mr-2"
onClick={handlePrevious}
>
<FontAwesomeIcon icon={faChevronLeft} className="mr-2" size="sm" />
<FormattedMessage
id="learn.sequence.navigation.after.unit.previous"
description="The button to go to the previous unit"
defaultMessage="Previous"
/>
</Button>
<Button
className="btn-outline-primary next-button w-75"
onClick={handleNext}
>
<FormattedMessage
id="learn.sequence.navigation.after.unit.next"
description="The button to go to the next unit"
defaultMessage="Next"
/>
<FontAwesomeIcon icon={faChevronRight} className="ml-2" size="sm" />
</Button>
</div>
) : null}
</>
);
}
Sequence.propTypes = {
activeUnitId: PropTypes.string.isRequired,
courseUsageKey: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
intl: intlShape.isRequired,
isGated: PropTypes.bool.isRequired,
onNavigateUnit: PropTypes.func,
onNext: PropTypes.func.isRequired,
onPrevious: PropTypes.func.isRequired,
showCompletion: PropTypes.bool.isRequired,
prerequisite: PropTypes.shape({
name: PropTypes.string,
id: PropTypes.string,
}).isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
bannerText: PropTypes.string,
};
Sequence.defaultProps = {
onNavigateUnit: null,
bannerText: undefined,
};
export default injectIntl(Sequence);

View File

@@ -1,69 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import classNames from 'classnames';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import UnitButton from './UnitButton';
export default function SequenceNavigation({
onNext,
onPrevious,
onNavigate,
unitIds,
isLocked,
showCompletion,
activeUnitId,
className,
}) {
const unitButtons = unitIds.map(unitId => (
<UnitButton
key={unitId}
unitId={unitId}
isActive={activeUnitId === unitId}
showCompletion={showCompletion}
onClick={onNavigate}
/>
));
return (
<nav className={classNames('sequence-navigation', className)}>
<Button className="previous-btn" onClick={onPrevious}>
<FontAwesomeIcon icon={faChevronLeft} className="mr-2" size="sm" />
<FormattedMessage
defaultMessage="Previous"
id="learn.sequence.navigation.previous.button"
description="The Previous button in the sequence nav"
/>
</Button>
{isLocked ? <UnitButton type="lock" isActive /> : unitButtons}
<Button className="next-btn" onClick={onNext}>
<FormattedMessage
defaultMessage="Next"
id="learn.sequence.navigation.next.button"
description="The Next button in the sequence nav"
/>
<FontAwesomeIcon icon={faChevronRight} className="ml-2" size="sm" />
</Button>
</nav>
);
}
SequenceNavigation.propTypes = {
className: PropTypes.string,
onNext: PropTypes.func.isRequired,
onPrevious: PropTypes.func.isRequired,
onNavigate: PropTypes.func.isRequired,
isLocked: PropTypes.bool.isRequired,
showCompletion: PropTypes.bool.isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
activeUnitId: PropTypes.string.isRequired,
};
SequenceNavigation.defaultProps = {
className: null,
};

View File

@@ -1,90 +0,0 @@
import React, { useRef, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { connect } from 'react-redux';
import BookmarkButton from './bookmark/BookmarkButton';
import { addBookmark, removeBookmark } from '../../data/course-blocks';
function Unit({
bookmarked,
bookmarkedUpdateState,
displayName,
onLoaded,
id,
...props
}) {
const iframeRef = useRef(null);
const iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0`;
const [iframeHeight, setIframeHeight] = useState(0);
const [hasLoaded, setHasLoaded] = useState(false);
useEffect(() => {
global.onmessage = (event) => {
const { type, payload } = event.data;
if (type === 'plugin.resize') {
setIframeHeight(payload.height);
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
setHasLoaded(true);
if (onLoaded) {
onLoaded();
}
}
}
};
}, []);
const toggleBookmark = () => {
if (bookmarked) {
props.removeBookmark(id);
} else {
props.addBookmark(id);
}
};
return (
<>
<div className="unit-content-container">
<h2 className="mb-0 h4">{displayName}</h2>
<BookmarkButton
onClick={toggleBookmark}
isBookmarked={bookmarked}
isProcessing={bookmarkedUpdateState === 'loading'}
/>
</div>
<iframe
id="unit-iframe"
title={displayName}
ref={iframeRef}
src={iframeUrl}
allowFullScreen
height={iframeHeight}
scrolling="no"
referrerPolicy="origin"
/>
</>
);
}
Unit.propTypes = {
addBookmark: PropTypes.func.isRequired,
bookmarked: PropTypes.bool,
bookmarkedUpdateState: PropTypes.string,
displayName: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
removeBookmark: PropTypes.func.isRequired,
onLoaded: PropTypes.func,
};
Unit.defaultProps = {
bookmarked: false,
bookmarkedUpdateState: undefined,
onLoaded: undefined,
};
const mapStateToProps = (state, props) => state.courseBlocks.blocks[props.id] || {};
export default connect(mapStateToProps, {
addBookmark,
removeBookmark,
})(Unit);

View File

@@ -1,11 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'learn.loading.content.lock': {
id: 'learn.loading.content.lock',
defaultMessage: 'Loading locked content messaging...',
description: 'Message shown when an interface about locked content is being loaded',
},
});
export default messages;

View File

@@ -1,67 +0,0 @@
/* eslint-disable no-plusplus */
import { camelCaseObject } from '@edx/frontend-platform';
export function createBlocksMap(blocksData) {
const blocks = {};
const blocksList = Object.values(blocksData);
// First go through the list and flesh out our blocks map, camelCasing the objects as we go.
for (let i = 0; i < blocksList.length; i++) {
const block = blocksList[i];
blocks[block.id] = camelCaseObject(block);
}
// Next go through the blocksList again - now that we've added them all to the blocks map - and
// append a parent ID to every child found in every `children` list, using the blocks map to find
// them.
for (let i = 0; i < blocksList.length; i++) {
const block = blocksList[i];
if (Array.isArray(block.children)) {
for (let j = 0; j < block.children.length; j++) {
const childId = block.children[j];
const child = blocks[childId];
child.parentId = block.id;
}
}
}
return blocks;
}
export function createSequenceIdList(blocks, entryPointId, sequences = []) {
const block = blocks[entryPointId];
if (block.type === 'sequential') {
sequences.push(block.id);
}
if (Array.isArray(block.children)) {
for (let i = 0; i < block.children.length; i++) {
const childId = block.children[i];
createSequenceIdList(blocks, childId, sequences);
}
}
return sequences;
}
export function createUnitIdList(blocks, entryPointId, units = []) {
const block = blocks[entryPointId];
if (block.type === 'vertical') {
units.push(block.id);
}
if (Array.isArray(block.children)) {
for (let i = 0; i < block.children.length; i++) {
const childId = block.children[i];
createUnitIdList(blocks, childId, units);
}
}
return units;
}
export function findBlockAncestry(blocks, blockId, descendents = []) {
const block = blocks[blockId];
descendents.unshift(block);
if (block.parentId === undefined) {
return descendents;
}
return findBlockAncestry(blocks, block.parentId, descendents);
}

176
src/data/api.js Normal file
View File

@@ -0,0 +1,176 @@
/* eslint-disable import/prefer-default-export */
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
function overrideTabUrls(id, tabs) {
// "LMS tab slug" to "MFE URL slug" for overridden tabs
const tabOverrides = {};
return tabs.map((tab) => {
let url;
if (tabOverrides[tab.slug]) {
url = `/course/${id}/${tabOverrides[tab.slug]}`;
} else {
url = `${getConfig().LMS_BASE_URL}${tab.url}`;
}
return { ...tab, url };
});
}
function normalizeMetadata(metadata) {
return {
canShowUpgradeSock: metadata.can_show_upgrade_sock,
contentTypeGatingEnabled: metadata.content_type_gating_enabled,
// TODO: TNL-7185: return course expired _date_, instead of _message_
courseExpiredMessage: metadata.course_expired_message,
id: metadata.id,
title: metadata.name,
number: metadata.number,
offerHtml: metadata.offer_html,
org: metadata.org,
enrollmentStart: metadata.enrollment_start,
enrollmentEnd: metadata.enrollment_end,
end: metadata.end,
start: metadata.start,
enrollmentMode: metadata.enrollment.mode,
isEnrolled: metadata.enrollment.is_active,
canLoadCourseware: camelCaseObject(metadata.can_load_courseware),
isStaff: metadata.is_staff,
verifiedMode: camelCaseObject(metadata.verified_mode),
tabs: overrideTabUrls(metadata.id, camelCaseObject(metadata.tabs)),
showCalculator: metadata.show_calculator,
notes: camelCaseObject(metadata.notes),
};
}
export async function getCourseMetadata(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeMetadata(data);
}
function normalizeBlocks(courseId, blocks) {
const models = {
courses: {},
sections: {},
sequences: {},
units: {},
};
Object.values(blocks).forEach(block => {
switch (block.type) {
case 'course':
models.courses[block.id] = {
id: courseId,
title: block.display_name,
sectionIds: block.children || [],
};
break;
case 'chapter':
models.sections[block.id] = {
id: block.id,
title: block.display_name,
sequenceIds: block.children || [],
};
break;
case 'sequential':
models.sequences[block.id] = {
id: block.id,
title: block.display_name,
lmsWebUrl: block.lms_web_url,
unitIds: block.children || [],
};
break;
case 'vertical':
models.units[block.id] = {
graded: block.graded,
id: block.id,
title: block.display_name,
lmsWebUrl: block.lms_web_url,
};
break;
default:
logError(`Unexpected course block type: ${block.type} with ID ${block.id}. Expected block types are course, chapter, sequential, and vertical.`);
}
});
// Next go through each list and use their child lists to decorate those children with a
// reference back to their parent.
Object.values(models.courses).forEach(course => {
if (Array.isArray(course.sectionIds)) {
course.sectionIds.forEach(sectionId => {
const section = models.sections[sectionId];
section.courseId = course.id;
});
}
});
Object.values(models.sections).forEach(section => {
if (Array.isArray(section.sequenceIds)) {
section.sequenceIds.forEach(sequenceId => {
models.sequences[sequenceId].sectionId = section.id;
});
}
});
Object.values(models.sequences).forEach(sequence => {
if (Array.isArray(sequence.unitIds)) {
sequence.unitIds.forEach(unitId => {
models.units[unitId].sequenceId = sequence.id;
});
}
});
return models;
}
export async function getCourseBlocks(courseId) {
const { username } = getAuthenticatedUser();
const url = new URL(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/`);
url.searchParams.append('course_id', courseId);
url.searchParams.append('username', username);
url.searchParams.append('depth', 3);
url.searchParams.append('requested_fields', 'children,show_gated_sections,graded');
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
return normalizeBlocks(courseId, data.blocks);
}
function normalizeSequenceMetadata(sequence) {
return {
sequence: {
id: sequence.item_id,
unitIds: sequence.items.map(unit => unit.id),
bannerText: sequence.banner_text,
title: sequence.display_name,
gatedContent: camelCaseObject(sequence.gated_content),
isTimeLimited: sequence.is_time_limited,
// Position comes back from the server 1-indexed. Adjust here.
activeUnitIndex: sequence.position ? sequence.position - 1 : 0,
saveUnitPosition: sequence.save_position,
showCompletion: sequence.show_completion,
},
units: sequence.items.map(unit => ({
id: unit.id,
sequenceId: sequence.item_id,
bookmarked: unit.bookmarked,
complete: unit.complete,
title: unit.page_title,
contentType: unit.type,
})),
};
}
export async function getSequenceMetadata(sequenceId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceId}`, {});
return normalizeSequenceMetadata(data);
}
export async function getResumeBlock(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`);
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
return camelCaseObject(data);
}

View File

@@ -1,109 +0,0 @@
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
export async function getCourseBlocks(courseUsageKey) {
const { username } = getAuthenticatedUser();
const url = new URL(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/`);
url.searchParams.append('course_id', courseUsageKey);
url.searchParams.append('username', username);
url.searchParams.append('depth', 3);
url.searchParams.append('requested_fields', 'children,show_gated_sections');
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
// Camelcase block objects (leave blockId keys alone)
const blocks = Object.entries(data.blocks).reduce((acc, [key, value]) => {
acc[key] = camelCaseObject(value);
return acc;
}, {});
// Next go through the blocksList again - now that we've added them all to the blocks map - and
// append a parent ID to every child found in every `children` list, using the blocks map to find
// them.
Object.values(blocks).forEach((block) => {
if (Array.isArray(block.children)) {
const parentId = block.id;
block.children.forEach((childBlockId) => {
blocks[childBlockId].parentId = parentId;
});
}
});
const processedData = camelCaseObject(data);
processedData.blocks = blocks;
return processedData;
}
export async function getSequenceMetadata(sequenceId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceId}`, {});
const camelCasedData = camelCaseObject(data);
camelCasedData.items = camelCasedData.items.map((item) => {
const processedItem = camelCaseObject(item);
processedItem.contentType = processedItem.type;
delete processedItem.type;
return processedItem;
});
// Position comes back from the server 1-indexed. Adjust here.
camelCasedData.position = camelCasedData.position ? camelCasedData.position - 1 : 0;
return camelCasedData;
}
const getSequenceXModuleHandlerUrl = (courseUsageKey, sequenceId) => `${getConfig().LMS_BASE_URL}/courses/${courseUsageKey}/xblock/${sequenceId}/handler/xmodule_handler`;
export async function updateSequencePosition(courseUsageKey, sequenceId, position) {
// Post data sent to this endpoint must be url encoded
// TODO: Remove the need for this to be the case.
// TODO: Ensure this usage of URLSearchParams is working in Internet Explorer
const urlEncoded = new URLSearchParams();
// Position is 1-indexed on the server and 0-indexed in this app. Adjust here.
urlEncoded.append('position', position + 1);
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
};
const { data } = await getAuthenticatedHttpClient().post(
`${getSequenceXModuleHandlerUrl(courseUsageKey, sequenceId)}/goto_position`,
urlEncoded.toString(),
requestConfig,
);
return data;
}
export async function getBlockCompletion(courseUsageKey, sequenceId, usageKey) {
// Post data sent to this endpoint must be url encoded
// TODO: Remove the need for this to be the case.
// TODO: Ensure this usage of URLSearchParams is working in Internet Explorer
const urlEncoded = new URLSearchParams();
urlEncoded.append('usage_key', usageKey);
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
};
const { data } = await getAuthenticatedHttpClient().post(
`${getSequenceXModuleHandlerUrl(courseUsageKey, sequenceId)}/get_completion`,
urlEncoded.toString(),
requestConfig,
);
if (data.complete) {
return true;
}
return false;
}
const bookmarksBaseUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
export async function createBookmark(usageId) {
return getAuthenticatedHttpClient().post(bookmarksBaseUrl, { usage_id: usageId });
}
export async function deleteBookmark(usageId) {
const { username } = getAuthenticatedUser();
return getAuthenticatedHttpClient().delete(`${bookmarksBaseUrl}${username},${usageId}/`);
}

View File

@@ -1,20 +0,0 @@
export {
getCourseBlocks,
getSequenceMetadata,
updateSequencePosition,
getBlockCompletion,
createBookmark,
deleteBookmark,
} from './api';
export {
reducer,
courseBlocksShape,
} from './slice';
export {
fetchCourseBlocks,
fetchSequenceMetadata,
checkBlockCompletion,
saveSequencePosition,
addBookmark,
removeBookmark,
} from './thunks';

View File

@@ -1,142 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import PropTypes from 'prop-types';
const blocksSlice = createSlice({
name: 'blocks',
initialState: {
fetchState: null,
root: null,
blocks: {},
},
reducers: {
/**
* fetchCourseBlocks
* This routine is responsible for fetching all blocks in a course.
*/
fetchCourseBlocksRequest: (draftState) => {
draftState.fetchState = 'loading';
},
fetchCourseBlocksSuccess: (draftState, { payload }) => ({
...payload,
fetchState: 'loaded',
loaded: true,
}),
fetchCourseBlocksFailure: (draftState) => {
draftState.fetchState = 'failed';
},
/**
* fetchBlockMetadata
* This routine is responsible for fetching metadata for any kind of
* block (sequential, vertical or any other block) and merging that
* data with what is in the store. Currently used for:
*
* - fetchSequenceMetadata
* - checkBlockCompletion (Vertical blocks)
*/
fetchBlockMetadataRequest: (draftState, action) => {
const { blockId } = action.payload;
if (!draftState.blocks[blockId]) {
draftState.blocks[blockId] = {};
}
draftState.blocks[blockId].fetchState = 'loading';
},
fetchBlockMetadataSuccess: (draftState, action) => {
const { blockId, metadata, relatedBlocksMetadata } = action.payload;
if (!draftState.blocks[blockId]) {
draftState.blocks[blockId] = {};
}
draftState.blocks[blockId] = {
...draftState.blocks[blockId],
...metadata,
fetchState: 'loaded',
loaded: true,
};
if (relatedBlocksMetadata) {
relatedBlocksMetadata.forEach((blockMetadata) => {
if (draftState.blocks[blockMetadata.id] === undefined) {
draftState.blocks[blockMetadata.id] = {};
}
draftState.blocks[blockMetadata.id] = {
...draftState.blocks[blockMetadata.id],
...blockMetadata,
};
});
}
},
fetchBlockMetadataFailure: (draftState, action) => {
const { blockId } = action.payload;
if (!draftState.blocks[blockId]) {
draftState.blocks[blockId] = {};
}
draftState.blocks[blockId].fetchState = 'failure';
},
/**
* updateBlock
* This routine is responsible for CRUD operations on block properties.
* Updates to blocks are handled in an optimistic way applying the update
* to the store at request time and then reverting it if the update fails.
*
* TODO: It may be helpful to add a flag to be optimistic or not.
*
* The update state of a property is added to the block in the store with
* a dynamic property name: ${propertyToUpdate}UpdateState.
* (e.g. bookmarkedUpdateState)
*
* Used in:
* - saveSequencePosition
* - addBookmark
* - removeBookmark
*/
updateBlockRequest: (draftState, action) => {
const { blockId, propertyToUpdate, updateValue } = action.payload;
const updateStateKey = `${propertyToUpdate}UpdateState`;
if (!draftState.blocks[blockId]) {
draftState.blocks[blockId] = {};
}
draftState.blocks[blockId][updateStateKey] = 'loading';
draftState.blocks[blockId][propertyToUpdate] = updateValue;
},
updateBlockSuccess: (draftState, action) => {
const { blockId, propertyToUpdate, updateValue } = action.payload;
const updateStateKey = `${propertyToUpdate}UpdateState`;
if (!draftState.blocks[blockId]) {
draftState.blocks[blockId] = {};
}
draftState.blocks[blockId][updateStateKey] = 'updated';
draftState.blocks[blockId][propertyToUpdate] = updateValue;
},
updateBlockFailure: (draftState, action) => {
const { blockId, propertyToUpdate, initialValue } = action.payload;
const updateStateKey = `${propertyToUpdate}UpdateState`;
if (!draftState.blocks[blockId]) {
draftState.blocks[blockId] = {};
}
draftState.blocks[blockId][updateStateKey] = 'failed';
draftState.blocks[blockId][propertyToUpdate] = initialValue;
},
},
});
export const {
fetchCourseBlocksRequest,
fetchCourseBlocksSuccess,
fetchCourseBlocksFailure,
fetchBlockMetadataRequest,
fetchBlockMetadataSuccess,
fetchBlockMetadataFailure,
updateBlockRequest,
updateBlockSuccess,
updateBlockFailure,
} = blocksSlice.actions;
export const { reducer } = blocksSlice;
export const courseBlocksShape = PropTypes.objectOf(PropTypes.shape({
id: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
children: PropTypes.arrayOf(PropTypes.string),
parentId: PropTypes.string,
}));

View File

@@ -1,124 +0,0 @@
import {
fetchCourseBlocksRequest,
fetchCourseBlocksSuccess,
fetchCourseBlocksFailure,
fetchBlockMetadataRequest,
fetchBlockMetadataSuccess,
fetchBlockMetadataFailure,
updateBlockRequest,
updateBlockSuccess,
updateBlockFailure,
} from './slice';
import {
getCourseBlocks,
getSequenceMetadata,
getBlockCompletion,
updateSequencePosition,
createBookmark,
deleteBookmark,
} from './api';
export function fetchCourseBlocks(courseUsageKey) {
return async (dispatch) => {
dispatch(fetchCourseBlocksRequest(courseUsageKey));
try {
const courseBlocks = await getCourseBlocks(courseUsageKey);
dispatch(fetchCourseBlocksSuccess(courseBlocks));
} catch (error) {
dispatch(fetchCourseBlocksFailure(error));
}
};
}
export function fetchSequenceMetadata(sequenceBlockId) {
return async (dispatch) => {
dispatch(fetchBlockMetadataRequest({ blockId: sequenceBlockId }));
try {
const sequenceMetadata = await getSequenceMetadata(sequenceBlockId);
dispatch(fetchBlockMetadataSuccess({
blockId: sequenceBlockId,
metadata: sequenceMetadata,
relatedBlocksMetadata: sequenceMetadata.items,
}));
} catch (error) {
dispatch(fetchBlockMetadataFailure({ blockId: sequenceBlockId }, error));
}
};
}
export function checkBlockCompletion(courseUsageKey, sequenceId, unitId) {
return async (dispatch, getState) => {
const { courseBlocks } = getState();
if (courseBlocks.blocks[unitId].complete) {
return; // do nothing. Things don't get uncompleted after they are completed.
}
const commonPayload = { blockId: unitId, fetchType: 'completion' };
dispatch(fetchBlockMetadataRequest(commonPayload));
try {
const isComplete = await getBlockCompletion(courseUsageKey, sequenceId, unitId);
dispatch(fetchBlockMetadataSuccess({
...commonPayload,
metadata: {
complete: isComplete,
},
}));
} catch (error) {
dispatch(fetchBlockMetadataFailure(commonPayload, error));
}
};
}
export function saveSequencePosition(courseUsageKey, sequenceId, position) {
return async (dispatch, getState) => {
const { courseBlocks } = getState();
const actionPayload = {
blockId: sequenceId,
propertyToUpdate: 'position',
updateValue: position,
initialValue: courseBlocks.blocks[sequenceId].position,
};
dispatch(updateBlockRequest(actionPayload));
try {
await updateSequencePosition(courseUsageKey, sequenceId, position);
dispatch(updateBlockSuccess(actionPayload));
} catch (error) {
dispatch(updateBlockFailure(actionPayload));
}
};
}
export function addBookmark(unitId) {
return async (dispatch) => {
const actionPayload = {
blockId: unitId,
propertyToUpdate: 'bookmarked',
updateValue: true,
initialValue: false,
};
dispatch(updateBlockRequest(actionPayload));
try {
await createBookmark(unitId);
dispatch(updateBlockSuccess(actionPayload));
} catch (error) {
dispatch(updateBlockFailure(actionPayload));
}
};
}
export function removeBookmark(unitId) {
return async (dispatch) => {
const actionPayload = {
blockId: unitId,
propertyToUpdate: 'bookmarked',
updateValue: false,
initialValue: true,
};
dispatch(updateBlockRequest(actionPayload));
try {
await deleteBookmark(unitId);
dispatch(updateBlockSuccess(actionPayload));
} catch (error) {
dispatch(updateBlockFailure(actionPayload));
}
};
}

View File

@@ -1,10 +0,0 @@
/* eslint-disable import/prefer-default-export */
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
export async function getCourseMetadata(courseUsageKey) {
const url = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseUsageKey}`;
const { data } = await getAuthenticatedHttpClient().get(url);
const processedData = camelCaseObject(data);
return processedData;
}

View File

@@ -1,6 +0,0 @@
export { getCourseMetadata } from './api';
export {
reducer,
courseMetadataShape,
} from './slice';
export { fetchCourseMetadata } from './thunks';

View File

@@ -1,93 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import PropTypes from 'prop-types';
const courseMetaSlice = createSlice({
name: 'course-meta',
initialState: {
fetchState: null,
},
reducers: {
fetchCourseMetadataRequest: (draftState) => {
draftState.fetchState = 'loading';
},
fetchCourseMetadataSuccess: (draftState, { payload }) => ({
fetchState: 'loaded',
/*
* NOTE: If you change the data saved here,
* update the courseMetadataShape below!
*/
// Course identifiers
name: payload.name,
number: payload.number,
org: payload.org,
// Enrollment dates
enrollmentStart: payload.enrollmentStart,
enrollmentEnd: payload.enrollmentEnd,
// Course dates
end: payload.end,
start: payload.start,
// User access/enrollment status
enrollmentMode: payload.enrollment.mode,
isEnrolled: payload.enrollment.isActive,
userHasAccess: payload.userHasAccess,
verifiedMode: payload.verifiedMode,
// Misc
tabs: payload.tabs,
}),
fetchCourseMetadataFailure: (draftState) => {
draftState.fetchState = 'failed';
},
},
});
export const {
fetchCourseMetadataRequest,
fetchCourseMetadataSuccess,
fetchCourseMetadataFailure,
} = courseMetaSlice.actions;
export const { reducer } = courseMetaSlice;
export const courseMetadataShape = PropTypes.shape({
fetchState: PropTypes.string,
// Course identifiers
name: PropTypes.string,
number: PropTypes.string,
org: PropTypes.string,
// Enrollment dates
enrollmentStart: PropTypes.string,
enrollmentEnd: PropTypes.string,
// User access/enrollment status
enrollmentMode: PropTypes.string,
isEnrolled: PropTypes.bool,
userHasAccess: PropTypes.bool,
verifiedMode: PropTypes.shape({
price: PropTypes.number.isRequired,
currency: PropTypes.string.isRequired,
currencySymbol: PropTypes.string.isRequired,
sku: PropTypes.string.isRequired,
upgradeUrl: PropTypes.string.isRequired,
}),
// Course dates
start: PropTypes.string,
end: PropTypes.string,
// Misc
tabs: PropTypes.arrayOf(PropTypes.shape({
priority: PropTypes.number,
slug: PropTypes.string,
title: PropTypes.string,
type: PropTypes.string,
url: PropTypes.string,
})),
});

View File

@@ -1,21 +0,0 @@
/* eslint-disable import/prefer-default-export */
import {
fetchCourseMetadataRequest,
fetchCourseMetadataSuccess,
fetchCourseMetadataFailure,
} from './slice';
import {
getCourseMetadata,
} from './api';
export function fetchCourseMetadata(courseUsageKey) {
return async (dispatch) => {
dispatch(fetchCourseMetadataRequest({ courseUsageKey }));
try {
const courseMetadata = await getCourseMetadata(courseUsageKey);
dispatch(fetchCourseMetadataSuccess(courseMetadata));
} catch (error) {
dispatch(fetchCourseMetadataFailure(error));
}
};
}

7
src/data/index.js Normal file
View File

@@ -0,0 +1,7 @@
export {
fetchCourse,
fetchSequence,
} from './thunks';
export { getResumeBlock } from './api';
export { reducer } from './slice';

61
src/data/slice.js Normal file
View File

@@ -0,0 +1,61 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const LOADING = 'loading';
export const LOADED = 'loaded';
export const FAILED = 'failed';
export const DENIED = 'denied';
const slice = createSlice({
name: 'courseware',
initialState: {
courseStatus: 'loading',
courseId: null,
sequenceStatus: 'loading',
sequenceId: null,
},
reducers: {
fetchCourseRequest: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = LOADING;
},
fetchCourseSuccess: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = LOADED;
},
fetchCourseFailure: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = FAILED;
},
fetchCourseDenied: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = DENIED;
},
fetchSequenceRequest: (state, { payload }) => {
state.sequenceId = payload.sequenceId;
state.sequenceStatus = LOADING;
},
fetchSequenceSuccess: (state, { payload }) => {
state.sequenceId = payload.sequenceId;
state.sequenceStatus = LOADED;
},
fetchSequenceFailure: (state, { payload }) => {
state.sequenceId = payload.sequenceId;
state.sequenceStatus = FAILED;
},
},
});
export const {
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,
fetchCourseDenied,
fetchSequenceRequest,
fetchSequenceSuccess,
fetchSequenceFailure,
} = slice.actions;
export const {
reducer,
} = slice;

107
src/data/thunks.js Normal file
View File

@@ -0,0 +1,107 @@
import { logError } from '@edx/frontend-platform/logging';
import {
getCourseMetadata,
getCourseBlocks,
getSequenceMetadata,
} from './api';
import {
addModelsMap, updateModel, updateModels, updateModelsMap, addModel,
} from '../model-store';
import {
fetchCourseRequest,
fetchCourseSuccess,
fetchCourseFailure,
fetchCourseDenied,
fetchSequenceRequest,
fetchSequenceSuccess,
fetchSequenceFailure,
} from './slice';
export function fetchCourse(courseId) {
return async (dispatch) => {
dispatch(fetchCourseRequest({ courseId }));
Promise.allSettled([
getCourseMetadata(courseId),
getCourseBlocks(courseId),
]).then(([courseMetadataResult, courseBlocksResult]) => {
if (courseMetadataResult.status === 'fulfilled') {
dispatch(addModel({
modelType: 'courses',
model: courseMetadataResult.value,
}));
}
if (courseBlocksResult.status === 'fulfilled') {
const {
courses, sections, sequences, units,
} = courseBlocksResult.value;
dispatch(updateModelsMap({
modelType: 'courses',
modelsMap: courses,
}));
dispatch(addModelsMap({
modelType: 'sections',
modelsMap: sections,
}));
// We update for sequences and units because the sequence metadata may have come back first.
dispatch(updateModelsMap({
modelType: 'sequences',
modelsMap: sequences,
}));
dispatch(updateModelsMap({
modelType: 'units',
modelsMap: units,
}));
}
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
const fetchedBlocks = courseBlocksResult.status === 'fulfilled';
// Log errors for each request if needed. Course block failures may occur
// even if the course metadata request is successful
if (!fetchedBlocks) {
logError(courseBlocksResult.reason);
}
if (!fetchedMetadata) {
logError(courseMetadataResult.reason);
}
if (fetchedMetadata) {
if (courseMetadataResult.value.canLoadCourseware.hasAccess && fetchedBlocks) {
// User has access
dispatch(fetchCourseSuccess({ courseId }));
return;
}
// User either doesn't have access or only has partial access
// (can't access course blocks)
dispatch(fetchCourseDenied({ courseId }));
return;
}
// Definitely an error happening
dispatch(fetchCourseFailure({ courseId }));
});
};
}
export function fetchSequence(sequenceId) {
return async (dispatch) => {
dispatch(fetchSequenceRequest({ sequenceId }));
try {
const { sequence, units } = await getSequenceMetadata(sequenceId);
dispatch(updateModel({
modelType: 'sequences',
model: sequence,
}));
dispatch(updateModels({
modelType: 'units',
models: units,
}));
dispatch(fetchSequenceSuccess({ sequenceId }));
} catch (error) {
logError(error);
dispatch(fetchSequenceFailure({ sequenceId }));
}
};
}

View File

@@ -1,24 +1,37 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
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 } from '../user-messages';
import Alert from '../user-messages/Alert';
import messages from './messages';
import { useEnrollClickHandler } from './hooks';
function EnrollmentAlert({ intl, courseId }) {
const { enrollClickHandler, loading } = useEnrollClickHandler(
courseId,
intl.formatMessage(messages['learning.enrollment.success']),
);
function EnrollmentAlert({ intl }) {
return (
<Alert type="error">
{intl.formatMessage(messages['learning.enrollment.alert'])}
{' '}
<a href={`${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`}>
<Button disabled={loading} className="btn-link p-0 border-0 align-top" onClick={enrollClickHandler}>
{intl.formatMessage(messages['learning.enrollment.enroll.now'])}
</a>
</Button>
{' '}
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
</Alert>
);
}
EnrollmentAlert.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default injectIntl(EnrollmentAlert);

View File

@@ -0,0 +1,37 @@
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 } from '../user-messages';
import messages from './messages';
import { useEnrollClickHandler } from './hooks';
function StaffEnrollmentAlert({ intl, courseId }) {
const { enrollClickHandler, loading } = useEnrollClickHandler(
courseId,
intl.formatMessage(messages['learning.enrollment.success']),
);
return (
<Alert type="info" dismissible>
{intl.formatMessage(messages['learning.staff.enrollment.alert'])}
{' '}
<Button disabled={loading} className="btn-link p-0 border-0 align-top" onClick={enrollClickHandler}>
{intl.formatMessage(messages['learning.enrollment.enroll.now'])}
</Button>
{' '}
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
</Alert>
);
}
StaffEnrollmentAlert.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default injectIntl(StaffEnrollmentAlert);

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