Compare commits
1 Commits
kdmccormic
...
djoy/menu_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
155ac821eb |
@@ -1,16 +1,16 @@
|
||||
NODE_ENV='development'
|
||||
PORT=2000
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:19000/learning'
|
||||
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/logout'
|
||||
LOGOUT_URL='http://localhost:18000/login'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='localhost:19000/orders'
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
|
||||
21
docs/decisions/0002-courseware-component-hierarchy.md
Normal file
21
docs/decisions/0002-courseware-component-hierarchy.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 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.
|
||||
@@ -1,50 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,7 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,7 +0,0 @@
|
||||
## 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)
|
||||
@@ -1,17 +0,0 @@
|
||||
# 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.
|
||||
30
docs/xblock-links.md
Normal file
30
docs/xblock-links.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 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
|
||||
8292
package-lock.json
generated
8292
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,6 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"dev-build": "fedx-scripts webpack-dev",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
@@ -38,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.1",
|
||||
"@edx/paragon": "^7.2.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.26",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.12.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.12.0",
|
||||
@@ -53,11 +52,12 @@
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "github:edx/frontend-build#kdmccormick/devstack-frontends",
|
||||
"@edx/frontend-build": "^3.0.0",
|
||||
"codecov": "^3.6.1",
|
||||
"es-check": "^5.1.0",
|
||||
"glob": "^7.1.6",
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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/`);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -33,5 +33,5 @@ export default class PageLoading extends Component {
|
||||
}
|
||||
|
||||
PageLoading.propTypes = {
|
||||
srMessage: PropTypes.node.isRequired,
|
||||
srMessage: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as Header } from './Header';
|
||||
export { default as CourseTabsNavigation } from './CourseTabsNavigation';
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learn.navigation.course.tabs.label': {
|
||||
id: 'learn.navigation.course.tabs.label',
|
||||
defaultMessage: 'Course Material',
|
||||
description: 'The accessible label for course tabs navigation',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,97 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import AlertList from '../user-messages/AlertList';
|
||||
import { Header, CourseTabsNavigation } from '../course-header';
|
||||
import { useLogistrationAlert } from '../logistration-alert';
|
||||
import { useEnrollmentAlert } from '../enrollment-alert';
|
||||
|
||||
import CourseDates from './CourseDates';
|
||||
import Section from './Section';
|
||||
import { useModel } from '../model-store';
|
||||
|
||||
// Note that we import from the component files themselves in the enrollment-alert package.
|
||||
// This is because Reacy.lazy() requires that we import() from a file with a Component as it's
|
||||
// default export.
|
||||
// See React.lazy docs here: https://reactjs.org/docs/code-splitting.html#reactlazy
|
||||
const { EnrollmentAlert, StaffEnrollmentAlert } = React.lazy(() => import('../enrollment-alert'));
|
||||
const LogistrationAlert = React.lazy(() => import('../logistration-alert'));
|
||||
|
||||
export default function CourseHome({
|
||||
courseId,
|
||||
}) {
|
||||
useLogistrationAlert();
|
||||
useEnrollmentAlert(courseId);
|
||||
|
||||
const {
|
||||
org,
|
||||
number,
|
||||
title,
|
||||
start,
|
||||
end,
|
||||
enrollmentStart,
|
||||
enrollmentEnd,
|
||||
enrollmentMode,
|
||||
isEnrolled,
|
||||
tabs,
|
||||
sectionIds,
|
||||
} = useModel('courses', courseId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
courseOrg={org}
|
||||
courseNumber={number}
|
||||
courseTitle={title}
|
||||
/>
|
||||
<main className="d-flex flex-column flex-grow-1">
|
||||
<div className="container-fluid">
|
||||
<CourseTabsNavigation tabs={tabs} className="mb-3" activeTabSlug="courseware" />
|
||||
<AlertList
|
||||
topic="outline"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
clientEnrollmentAlert: EnrollmentAlert,
|
||||
clientStaffEnrollmentAlert: StaffEnrollmentAlert,
|
||||
clientLogistrationAlert: LogistrationAlert,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow-1">
|
||||
<div className="container-fluid">
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<h2>{title}</h2>
|
||||
<Button className="btn-primary" type="button">Resume Course</Button>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col col-8">
|
||||
{sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={sectionId}
|
||||
id={sectionId}
|
||||
courseId={courseId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="col col-4">
|
||||
<CourseDates
|
||||
start={start}
|
||||
end={end}
|
||||
enrollmentStart={enrollmentStart}
|
||||
enrollmentEnd={enrollmentEnd}
|
||||
enrollmentMode={enrollmentMode}
|
||||
isEnrolled={isEnrolled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CourseHome.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import PageLoading from '../PageLoading';
|
||||
import CourseHome from './CourseHome';
|
||||
import { fetchCourse } from '../data';
|
||||
|
||||
function CourseHomeContainer(props) {
|
||||
const {
|
||||
intl,
|
||||
match,
|
||||
} = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
// The courseId from the URL is the course we WANT to load.
|
||||
dispatch(fetchCourse(match.params.courseId));
|
||||
}, [match.params.courseId]);
|
||||
|
||||
// The courseId from the store is the course we HAVE loaded. If the URL changes,
|
||||
// we don't want the application to adjust to it until it has actually loaded the new data.
|
||||
const {
|
||||
courseId,
|
||||
courseStatus,
|
||||
} = useSelector(state => state.courseware);
|
||||
|
||||
return (
|
||||
<>
|
||||
{courseStatus === 'loaded' ? (
|
||||
<CourseHome
|
||||
courseId={courseId}
|
||||
/>
|
||||
) : (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.outline'])}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CourseHomeContainer.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
courseId: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseHomeContainer);
|
||||
@@ -1,18 +0,0 @@
|
||||
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={`/learning/course/${courseId}/${id}`}>{sequence.title}</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SequenceLink.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './CourseHomeContainer';
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learn.loading.outline': {
|
||||
id: 'learn.loading.learning.sequence',
|
||||
defaultMessage: 'Loading learning sequence...',
|
||||
description: 'Message when learning sequence is being loaded',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
110
src/courseware/CourseContainer.jsx
Normal file
110
src/courseware/CourseContainer.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
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));
|
||||
@@ -1,191 +0,0 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
|
||||
import { useRouteMatch, Redirect } from 'react-router';
|
||||
import {
|
||||
fetchCourse,
|
||||
fetchSequence,
|
||||
} from '../data';
|
||||
import {
|
||||
checkBlockCompletion,
|
||||
saveSequencePosition,
|
||||
} from './data/thunks';
|
||||
import { useModel } from '../model-store';
|
||||
|
||||
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(`/learning/course/${courseId}/${sequenceId}/${nextUnitId}`);
|
||||
}, [courseId, sequenceId]);
|
||||
}
|
||||
|
||||
function usePreviousSequence(sequenceId) {
|
||||
const sequenceIds = useSelector(sequenceIdsSelector);
|
||||
const sequences = useSelector(state => state.models.sequences);
|
||||
if (!sequenceId || sequenceIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sequenceIndex = sequenceIds.indexOf(sequenceId);
|
||||
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
|
||||
return previousSequenceId !== null ? sequences[previousSequenceId] : null;
|
||||
}
|
||||
|
||||
function useNextSequence(sequenceId) {
|
||||
const sequenceIds = useSelector(sequenceIdsSelector);
|
||||
const sequences = useSelector(state => state.models.sequences);
|
||||
if (!sequenceId || sequenceIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sequenceIndex = sequenceIds.indexOf(sequenceId);
|
||||
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
|
||||
return nextSequenceId !== null ? sequences[nextSequenceId] : null;
|
||||
}
|
||||
|
||||
|
||||
function useNextSequenceHandler(courseId, sequenceId) {
|
||||
const nextSequence = useNextSequence(sequenceId);
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
return useCallback(() => {
|
||||
if (nextSequence !== null) {
|
||||
const nextUnitId = nextSequence.unitIds[0];
|
||||
history.push(`/learning/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
|
||||
}
|
||||
}, [courseStatus, sequenceStatus, sequenceId]);
|
||||
}
|
||||
|
||||
function usePreviousSequenceHandler(courseId, sequenceId) {
|
||||
const previousSequence = usePreviousSequence(sequenceId);
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
return useCallback(() => {
|
||||
if (previousSequence !== null) {
|
||||
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
|
||||
history.push(`/learning/course/${courseId}/${previousSequence.id}/${previousUnitId}`);
|
||||
}
|
||||
}, [courseStatus, sequenceStatus, sequenceId]);
|
||||
}
|
||||
|
||||
function useExamRedirect(sequenceId) {
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
useEffect(() => {
|
||||
if (sequenceStatus === 'loaded' && sequence.isTimeLimited) {
|
||||
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) {
|
||||
// This is a replace because we don't want this change saved in the browser's history.
|
||||
history.replace(`/learning/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(`/learning/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);
|
||||
|
||||
if (courseStatus === 'denied') {
|
||||
return <Redirect to={`/learning/redirect/course-home/${courseId}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-grow-1 d-flex flex-column">
|
||||
<Course
|
||||
courseId={courseId}
|
||||
sequenceId={sequenceId}
|
||||
unitId={routeUnitId}
|
||||
nextSequenceHandler={nextSequenceHandler}
|
||||
previousSequenceHandler={previousSequenceHandler}
|
||||
unitNavigationHandler={unitNavigationHandler}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
CoursewareContainer.propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
courseId: PropTypes.string.isRequired,
|
||||
sequenceId: PropTypes.string,
|
||||
unitId: PropTypes.string,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
46
src/courseware/InstructorToolbar.jsx
Normal file
46
src/courseware/InstructorToolbar.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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);
|
||||
@@ -1,129 +1,138 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import AlertList from '../../user-messages/AlertList';
|
||||
import { useLogistrationAlert } from '../../logistration-alert';
|
||||
import { useEnrollmentAlert } from '../../enrollment-alert';
|
||||
import PageLoading from '../../PageLoading';
|
||||
|
||||
import InstructorToolbar from './InstructorToolbar';
|
||||
import Sequence from './sequence';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import { Header, CourseTabsNavigation } from '../../course-header';
|
||||
import SequenceContainer from './SequenceContainer';
|
||||
import { createSequenceIdList } from '../utils';
|
||||
import AlertList from '../../user-messages/AlertList';
|
||||
import CourseHeader from './CourseHeader';
|
||||
import CourseSock from './course-sock';
|
||||
import Calculator from './calculator';
|
||||
import messages from './messages';
|
||||
import { useModel } from '../../model-store';
|
||||
import CourseTabsNavigation from './CourseTabsNavigation';
|
||||
import InstructorToolbar from '../InstructorToolbar';
|
||||
import { useLogistrationAlert, useEnrollmentAlert } from '../../hooks';
|
||||
|
||||
// Note that we import from the component files themselves in the enrollment-alert package.
|
||||
// This is because Reacy.lazy() requires that we import() from a file with a Component as it's
|
||||
// default export.
|
||||
// See React.lazy docs here: https://reactjs.org/docs/code-splitting.html#reactlazy
|
||||
const EnrollmentAlert = React.lazy(() => import('../../enrollment-alert/EnrollmentAlert'));
|
||||
const StaffEnrollmentAlert = React.lazy(() => import('../../enrollment-alert/StaffEnrollmentAlert'));
|
||||
const EnrollmentAlert = React.lazy(() => import('../../enrollment-alert'));
|
||||
const LogistrationAlert = React.lazy(() => import('../../logistration-alert'));
|
||||
|
||||
function Course({
|
||||
|
||||
export default function Course({
|
||||
courseId,
|
||||
courseNumber,
|
||||
courseName,
|
||||
courseOrg,
|
||||
courseUsageKey,
|
||||
isEnrolled,
|
||||
models,
|
||||
sequenceId,
|
||||
tabs,
|
||||
unitId,
|
||||
nextSequenceHandler,
|
||||
previousSequenceHandler,
|
||||
unitNavigationHandler,
|
||||
intl,
|
||||
verifiedMode,
|
||||
}) {
|
||||
const course = useModel('courses', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const section = useModel('sections', sequence ? sequence.sectionId : null);
|
||||
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 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}`);
|
||||
}
|
||||
});
|
||||
|
||||
useLogistrationAlert();
|
||||
useEnrollmentAlert(courseId);
|
||||
useEnrollmentAlert(isEnrolled);
|
||||
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
|
||||
if (courseStatus === 'loading') {
|
||||
return (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (courseStatus === 'loaded') {
|
||||
const {
|
||||
org, number, title, isStaff, tabs, verifiedMode, showCalculator,
|
||||
} = course;
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
courseOrg={org}
|
||||
courseNumber={number}
|
||||
courseTitle={title}
|
||||
/>
|
||||
{isStaff && (
|
||||
<InstructorToolbar
|
||||
unitId={unitId}
|
||||
/>
|
||||
)}
|
||||
<CourseTabsNavigation tabs={tabs} activeTabSlug="courseware" />
|
||||
<div className="container-fluid">
|
||||
<AlertList
|
||||
className="my-3"
|
||||
topic="course"
|
||||
customAlerts={{
|
||||
clientEnrollmentAlert: EnrollmentAlert,
|
||||
clientStaffEnrollmentAlert: StaffEnrollmentAlert,
|
||||
clientLogistrationAlert: LogistrationAlert,
|
||||
}}
|
||||
/>
|
||||
<CourseBreadcrumbs
|
||||
courseId={courseId}
|
||||
sectionId={section ? section.id : null}
|
||||
sequenceId={sequenceId}
|
||||
/>
|
||||
<AlertList topic="sequence" />
|
||||
</div>
|
||||
<div className="flex-grow-1 d-flex flex-column">
|
||||
<Sequence
|
||||
unitId={unitId}
|
||||
sequenceId={sequenceId}
|
||||
courseId={courseId}
|
||||
unitNavigationHandler={unitNavigationHandler}
|
||||
nextSequenceHandler={nextSequenceHandler}
|
||||
previousSequenceHandler={previousSequenceHandler}
|
||||
/>
|
||||
{verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
|
||||
{showCalculator && <Calculator />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// courseStatus 'failed' and any other unexpected course status.
|
||||
return (
|
||||
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
|
||||
{intl.formatMessage(messages['learn.course.load.failure'])}
|
||||
</p>
|
||||
<>
|
||||
<CourseHeader
|
||||
courseOrg={courseOrg}
|
||||
courseNumber={courseNumber}
|
||||
courseName={courseName}
|
||||
/>
|
||||
<InstructorToolbar
|
||||
courseUsageKey={courseUsageKey}
|
||||
courseId={courseId}
|
||||
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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Course.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
sequenceId: PropTypes.string,
|
||||
courseOrg: PropTypes.string.isRequired,
|
||||
courseNumber: PropTypes.string.isRequired,
|
||||
courseName: PropTypes.string.isRequired,
|
||||
courseUsageKey: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
sequenceId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string,
|
||||
nextSequenceHandler: PropTypes.func.isRequired,
|
||||
previousSequenceHandler: PropTypes.func.isRequired,
|
||||
unitNavigationHandler: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
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,
|
||||
}),
|
||||
};
|
||||
|
||||
Course.defaultProps = {
|
||||
courseId: null,
|
||||
sequenceId: null,
|
||||
unitId: null,
|
||||
unitId: undefined,
|
||||
isEnrolled: false,
|
||||
verifiedMode: null,
|
||||
};
|
||||
|
||||
export default injectIntl(Course);
|
||||
|
||||
20
src/courseware/course/CourseBreadcrumb.jsx
Normal file
20
src/courseware/course/CourseBreadcrumb.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
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,
|
||||
};
|
||||
@@ -4,83 +4,39 @@ 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';
|
||||
|
||||
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,
|
||||
};
|
||||
import CourseBreadcrumb from './CourseBreadcrumb';
|
||||
|
||||
export default function CourseBreadcrumbs({
|
||||
courseId,
|
||||
sectionId,
|
||||
sequenceId,
|
||||
courseUsageKey, courseId, sequenceId, models,
|
||||
}) {
|
||||
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(() => {
|
||||
if (courseStatus === 'loaded' && sequenceStatus === 'loaded') {
|
||||
return [section, sequence].filter(node => !!node).map((node) => ({
|
||||
const sectionId = models[sequenceId].parentId;
|
||||
return [sectionId, sequenceId].map((nodeId) => {
|
||||
const node = models[nodeId];
|
||||
return {
|
||||
id: node.id,
|
||||
label: node.title,
|
||||
url: `${getConfig().LMS_BASE_URL}/courses/${course.id}/course/#${node.id}`,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}, [courseStatus, sequenceStatus]);
|
||||
label: node.displayName,
|
||||
url: `${getConfig().LMS_BASE_URL}/courses/${courseUsageKey}/course/#${node.id}`,
|
||||
};
|
||||
});
|
||||
}, [courseUsageKey, courseId, sequenceId, models]);
|
||||
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="my-4">
|
||||
<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 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>
|
||||
</nav>
|
||||
@@ -88,12 +44,13 @@ export default function CourseBreadcrumbs({
|
||||
}
|
||||
|
||||
CourseBreadcrumbs.propTypes = {
|
||||
courseUsageKey: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
sectionId: PropTypes.string,
|
||||
sequenceId: PropTypes.string,
|
||||
};
|
||||
|
||||
CourseBreadcrumbs.defaultProps = {
|
||||
sectionId: null,
|
||||
sequenceId: null,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -4,10 +4,7 @@ import { Dropdown } from '@edx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import logo from './assets/logo.svg';
|
||||
import logo from './logo.svg';
|
||||
|
||||
function LinkedLogo({
|
||||
href,
|
||||
@@ -28,13 +25,13 @@ LinkedLogo.propTypes = {
|
||||
alt: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default function Header({
|
||||
courseOrg, courseNumber, courseTitle,
|
||||
export default function CourseHeader({
|
||||
courseOrg, courseNumber, courseName,
|
||||
}) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<header className="course-header">
|
||||
<header>
|
||||
<div className="container-fluid py-2 d-flex align-items-center ">
|
||||
<LinkedLogo
|
||||
className="logo"
|
||||
@@ -42,17 +39,14 @@ export default function Header({
|
||||
src={logo}
|
||||
alt={getConfig().SITE_NAME}
|
||||
/>
|
||||
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
|
||||
<div className="flex-grow-1" style={{ lineHeight: 1 }}>
|
||||
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
|
||||
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
|
||||
<span className="d-block m-0 font-weight-bold">{courseName}</span>
|
||||
</div>
|
||||
|
||||
<Dropdown className="user-dropdown">
|
||||
<Dropdown>
|
||||
<Dropdown.Button>
|
||||
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
|
||||
<span className="d-none d-md-inline">
|
||||
{authenticatedUser.username}
|
||||
</span>
|
||||
{authenticatedUser.username}
|
||||
</Dropdown.Button>
|
||||
<Dropdown.Menu className="dropdown-menu-right">
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>Dashboard</Dropdown.Item>
|
||||
@@ -67,8 +61,8 @@ export default function Header({
|
||||
);
|
||||
}
|
||||
|
||||
Header.propTypes = {
|
||||
CourseHeader.propTypes = {
|
||||
courseOrg: PropTypes.string.isRequired,
|
||||
courseNumber: PropTypes.string.isRequired,
|
||||
courseTitle: PropTypes.string.isRequired,
|
||||
courseName: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -1,32 +1,31 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import messages from './messages';
|
||||
import Tabs from '../tabs/Tabs';
|
||||
import NavTab from './NavTab';
|
||||
|
||||
function CourseTabsNavigation({
|
||||
activeTabSlug, tabs, intl,
|
||||
activeTabSlug, tabs, intl, className,
|
||||
}) {
|
||||
const courseNavTabs = tabs.map(({ slug, ...courseTab }) => (
|
||||
<NavTab
|
||||
isActive={slug === activeTabSlug}
|
||||
key={slug}
|
||||
{...courseTab}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="course-tabs-navigation">
|
||||
<div className="container-fluid">
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
<nav
|
||||
aria-label={intl.formatMessage(messages['learn.navigation.course.tabs.label'])}
|
||||
className={classNames('nav nav-underline-tabs', className)}
|
||||
>
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<a
|
||||
key={slug}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
|
||||
href={`${getConfig().LMS_BASE_URL}${url}`}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</Tabs>
|
||||
{courseNavTabs}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -34,6 +33,7 @@ function CourseTabsNavigation({
|
||||
|
||||
CourseTabsNavigation.propTypes = {
|
||||
activeTabSlug: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
tabs: PropTypes.arrayOf(PropTypes.shape({
|
||||
title: PropTypes.string.isRequired,
|
||||
priority: PropTypes.number.isRequired,
|
||||
@@ -45,6 +45,7 @@ CourseTabsNavigation.propTypes = {
|
||||
|
||||
CourseTabsNavigation.defaultProps = {
|
||||
activeTabSlug: undefined,
|
||||
className: null,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseTabsNavigation);
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { Collapsible } from '@edx/paragon';
|
||||
|
||||
function InstructorToolbar(props) {
|
||||
// TODO: Only render this toolbar if the user is course staff
|
||||
if (!props.activeUnitLmsWebUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-primary text-light">
|
||||
<div className="container-fluid py-3 d-md-flex justify-content-end align-items-center">
|
||||
<div className="flex-grow-1">
|
||||
<Collapsible.Advanced className="mr-5 mb-md-0">
|
||||
You are currently previewing the new learning sequence experience.
|
||||
<Collapsible.Trigger className="d-inline-block ml-2" style={{ cursor: 'pointer' }}>
|
||||
<Collapsible.Visible whenClosed>
|
||||
<span style={{ borderBottom: 'solid 1px white' }}>More info</span> →
|
||||
</Collapsible.Visible>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Body>
|
||||
This preview is to allow for early content testing, especially for custom content blocks, with the goal of ensuring it renders as expected in the next experience. You can learn more through the following <a className="text-white" style={{ textDecoration: 'underline' }} href="https://partners.edx.org/announcements/author-preview-learning-sequence-experience-update" target="blank" rel="noopener">Partner Portal post</a>. Please report any issues or provide <a className="text-white" style={{ textDecoration: 'underline' }} target="blank" rel="noopener" href="https://forms.gle/R6jMYJNTCj1vgC1D6">feedback using the linked form</a>.
|
||||
<Collapsible.Trigger className="d-inline-block ml-2" style={{ cursor: 'pointer' }}>
|
||||
<Collapsible.Visible whenOpen>
|
||||
<span style={{ borderBottom: 'solid 1px white' }}>Close</span> ×
|
||||
</Collapsible.Visible>
|
||||
</Collapsible.Trigger>
|
||||
</Collapsible.Body>
|
||||
</Collapsible.Advanced>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<a className="btn d-block btn-outline-light" href={props.activeUnitLmsWebUrl}>View unit in the existing experience</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
InstructorToolbar.propTypes = {
|
||||
activeUnitLmsWebUrl: PropTypes.string,
|
||||
};
|
||||
|
||||
InstructorToolbar.defaultProps = {
|
||||
activeUnitLmsWebUrl: undefined,
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
if (!props.unitId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const activeUnit = state.models.units[props.unitId];
|
||||
return {
|
||||
activeUnitLmsWebUrl: activeUnit ? activeUnit.lmsWebUrl : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(InstructorToolbar);
|
||||
246
src/courseware/course/Menu.jsx
Normal file
246
src/courseware/course/Menu.jsx
Normal file
@@ -0,0 +1,246 @@
|
||||
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',
|
||||
};
|
||||
33
src/courseware/course/NavTab.jsx
Normal file
33
src/courseware/course/NavTab.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
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,
|
||||
};
|
||||
160
src/courseware/course/SequenceContainer.jsx
Normal file
160
src/courseware/course/SequenceContainer.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
/* 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));
|
||||
@@ -1,13 +0,0 @@
|
||||
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}/`);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
|
||||
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',
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as BookmarkButton } from './BookmarkButton';
|
||||
export { default as BookmarkFilledIcon } from './BookmarkFilledIcon';
|
||||
export { default as BookmarkOutlineIcon } from './BookmarkFilledIcon';
|
||||
@@ -1,27 +0,0 @@
|
||||
.calculator {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
.calculator-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;
|
||||
&:before {
|
||||
border-radius: .5rem;
|
||||
}
|
||||
}
|
||||
.calculator-content {
|
||||
background-color: #f1f1f1;
|
||||
box-shadow: 0 -1px 0 0 #ddd;
|
||||
}
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
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="container-fluid text-right">
|
||||
<Collapsible.Trigger tag="a" className="calculator-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);
|
||||
@@ -1,31 +0,0 @@
|
||||
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;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './Course';
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -1,15 +1,10 @@
|
||||
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.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.navigation.course.tabs.label': {
|
||||
id: 'learn.navigation.course.tabs.label',
|
||||
defaultMessage: 'Course Material',
|
||||
description: 'The accessible label for course tabs navigation',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
/* eslint-disable no-use-before-define */
|
||||
import React, {
|
||||
useEffect, useContext, Suspense, 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 Unit from './Unit';
|
||||
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
||||
import PageLoading from '../../../PageLoading';
|
||||
import messages from './messages';
|
||||
import UserMessagesContext from '../../../user-messages/UserMessagesContext';
|
||||
import { useModel } from '../../../model-store';
|
||||
|
||||
const ContentLock = React.lazy(() => import('./content-lock'));
|
||||
|
||||
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: '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">
|
||||
{gated && (
|
||||
<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>
|
||||
)}
|
||||
{!gated && unitId !== null && (
|
||||
<Unit
|
||||
key={unitId}
|
||||
id={unitId}
|
||||
onLoaded={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);
|
||||
@@ -1,66 +0,0 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import BookmarkButton from '../bookmark/BookmarkButton';
|
||||
import { useModel } from '../../../model-store';
|
||||
|
||||
export default function Unit({
|
||||
onLoaded,
|
||||
id,
|
||||
}) {
|
||||
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);
|
||||
|
||||
const unit = useModel('units', id);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="unit">
|
||||
<h2 className="mb-0 h4">{unit.title}</h2>
|
||||
<BookmarkButton
|
||||
unitId={unit.id}
|
||||
isBookmarked={unit.bookmarked}
|
||||
isProcessing={unit.bookmarkedUpdateState === 'loading'}
|
||||
/>
|
||||
<div className="unit-iframe-wrapper">
|
||||
<iframe
|
||||
id="unit-iframe"
|
||||
title={unit.title}
|
||||
ref={iframeRef}
|
||||
src={iframeUrl}
|
||||
allowFullScreen
|
||||
height={iframeHeight}
|
||||
scrolling="no"
|
||||
referrerPolicy="origin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Unit.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
onLoaded: PropTypes.func,
|
||||
};
|
||||
|
||||
Unit.defaultProps = {
|
||||
onLoaded: undefined,
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './Sequence';
|
||||
@@ -1,21 +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',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,84 +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 { 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,
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
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">🤗</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,
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
/* 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 };
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as SequenceNavigation } from './SequenceNavigation';
|
||||
export { default as UnitNavigation } from './UnitNavigation';
|
||||
@@ -1,47 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/* 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];
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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 +0,0 @@
|
||||
export { default } from './CoursewareContainer';
|
||||
@@ -1,6 +1,11 @@
|
||||
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}',
|
||||
|
||||
174
src/courseware/sequence/Sequence.jsx
Normal file
174
src/courseware/sequence/Sequence.jsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/* 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);
|
||||
69
src/courseware/sequence/SequenceNavigation.jsx
Normal file
69
src/courseware/sequence/SequenceNavigation.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
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,
|
||||
};
|
||||
90
src/courseware/sequence/Unit.jsx
Normal file
90
src/courseware/sequence/Unit.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
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);
|
||||
@@ -6,19 +6,17 @@ 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,
|
||||
title,
|
||||
displayName,
|
||||
contentType,
|
||||
isActive,
|
||||
bookmarked,
|
||||
complete,
|
||||
showCompletion,
|
||||
unitId,
|
||||
className,
|
||||
showTitle,
|
||||
}) {
|
||||
const handleClick = useCallback(() => {
|
||||
onClick(unitId);
|
||||
@@ -29,12 +27,11 @@ function UnitButton({
|
||||
className={classNames({
|
||||
active: isActive,
|
||||
complete: showCompletion && complete,
|
||||
}, className)}
|
||||
})}
|
||||
onClick={handleClick}
|
||||
title={title}
|
||||
title={displayName}
|
||||
>
|
||||
<UnitIcon type={contentType} />
|
||||
{showTitle && <span className="unit-title">{title}</span>}
|
||||
{showCompletion && complete ? <CompleteIcon size="sm" className="text-success ml-2" /> : null}
|
||||
{bookmarked ? (
|
||||
<BookmarkFilledIcon
|
||||
@@ -47,29 +44,25 @@ function UnitButton({
|
||||
}
|
||||
|
||||
UnitButton.propTypes = {
|
||||
bookmarked: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
complete: PropTypes.bool,
|
||||
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,
|
||||
isActive: PropTypes.bool,
|
||||
bookmarked: PropTypes.bool,
|
||||
complete: PropTypes.bool,
|
||||
showCompletion: PropTypes.bool,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
contentType: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
UnitButton.defaultProps = {
|
||||
className: undefined,
|
||||
isActive: false,
|
||||
bookmarked: false,
|
||||
complete: false,
|
||||
showTitle: false,
|
||||
showCompletion: true,
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
...state.models.units[props.unitId],
|
||||
...state.courseBlocks.blocks[props.unitId],
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(UnitButton);
|
||||
@@ -1,11 +1,9 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React 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
|
||||
@@ -23,25 +21,14 @@ const hasBookmarkLabel = (
|
||||
/>
|
||||
);
|
||||
|
||||
export default function BookmarkButton({
|
||||
isBookmarked, isProcessing, unitId,
|
||||
}) {
|
||||
export default function BookmarkButton({ onClick, isBookmarked, isProcessing }) {
|
||||
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={toggleBookmark}
|
||||
onClick={onClick}
|
||||
state={state}
|
||||
disabledStates={['defaultProcessing', 'bookmarkedProcessing']}
|
||||
labels={{
|
||||
@@ -61,11 +48,7 @@ export default function BookmarkButton({
|
||||
}
|
||||
|
||||
BookmarkButton.propTypes = {
|
||||
unitId: PropTypes.string.isRequired,
|
||||
isBookmarked: PropTypes.bool,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
isBookmarked: PropTypes.bool.isRequired,
|
||||
isProcessing: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
BookmarkButton.defaultProps = {
|
||||
isBookmarked: false,
|
||||
};
|
||||
@@ -9,10 +9,10 @@ import { Button } from '@edx/paragon';
|
||||
import messages from './messages';
|
||||
|
||||
function ContentLock({
|
||||
intl, courseId, prereqSectionName, prereqId, sequenceTitle,
|
||||
intl, courseUsageKey, prereqSectionName, prereqId, sectionName,
|
||||
}) {
|
||||
const handleClick = useCallback(() => {
|
||||
history.push(`/learning/course/${courseId}/${prereqId}`);
|
||||
history.push(`/course/${courseUsageKey}/${prereqId}`);
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -20,7 +20,7 @@ function ContentLock({
|
||||
<h3>
|
||||
<FontAwesomeIcon icon={faLock} />
|
||||
{' '}
|
||||
{sequenceTitle}
|
||||
{sectionName}
|
||||
</h3>
|
||||
<h4>{intl.formatMessage(messages['learn.contentLock.content.locked'])}</h4>
|
||||
<p>
|
||||
@@ -36,9 +36,9 @@ function ContentLock({
|
||||
}
|
||||
ContentLock.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
courseUsageKey: PropTypes.string.isRequired,
|
||||
prereqSectionName: PropTypes.string.isRequired,
|
||||
prereqId: PropTypes.string.isRequired,
|
||||
sequenceTitle: PropTypes.string.isRequired,
|
||||
sectionName: PropTypes.string.isRequired,
|
||||
};
|
||||
export default injectIntl(ContentLock);
|
||||
11
src/courseware/sequence/messages.js
Normal file
11
src/courseware/sequence/messages.js
Normal file
@@ -0,0 +1,11 @@
|
||||
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;
|
||||
67
src/courseware/utils.js
Normal file
67
src/courseware/utils.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/* 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);
|
||||
}
|
||||
147
src/data/api.js
147
src/data/api.js
@@ -1,147 +0,0 @@
|
||||
/* 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 normalizeMetadata(metadata) {
|
||||
return {
|
||||
id: metadata.id,
|
||||
title: metadata.name,
|
||||
number: metadata.number,
|
||||
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: metadata.can_load_courseware,
|
||||
isStaff: metadata.is_staff,
|
||||
verifiedMode: camelCaseObject(metadata.verified_mode),
|
||||
tabs: camelCaseObject(metadata.tabs),
|
||||
showCalculator: metadata.show_calculator,
|
||||
};
|
||||
}
|
||||
|
||||
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] = {
|
||||
id: block.id,
|
||||
title: block.display_name,
|
||||
};
|
||||
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');
|
||||
|
||||
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);
|
||||
}
|
||||
109
src/data/course-blocks/api.js
Normal file
109
src/data/course-blocks/api.js
Normal file
@@ -0,0 +1,109 @@
|
||||
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}/`);
|
||||
}
|
||||
20
src/data/course-blocks/index.js
Normal file
20
src/data/course-blocks/index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
getCourseBlocks,
|
||||
getSequenceMetadata,
|
||||
updateSequencePosition,
|
||||
getBlockCompletion,
|
||||
createBookmark,
|
||||
deleteBookmark,
|
||||
} from './api';
|
||||
export {
|
||||
reducer,
|
||||
courseBlocksShape,
|
||||
} from './slice';
|
||||
export {
|
||||
fetchCourseBlocks,
|
||||
fetchSequenceMetadata,
|
||||
checkBlockCompletion,
|
||||
saveSequencePosition,
|
||||
addBookmark,
|
||||
removeBookmark,
|
||||
} from './thunks';
|
||||
142
src/data/course-blocks/slice.js
Normal file
142
src/data/course-blocks/slice.js
Normal file
@@ -0,0 +1,142 @@
|
||||
/* 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,
|
||||
}));
|
||||
124
src/data/course-blocks/thunks.js
Normal file
124
src/data/course-blocks/thunks.js
Normal file
@@ -0,0 +1,124 @@
|
||||
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));
|
||||
}
|
||||
};
|
||||
}
|
||||
10
src/data/course-meta/api.js
Normal file
10
src/data/course-meta/api.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/* 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;
|
||||
}
|
||||
6
src/data/course-meta/index.js
Normal file
6
src/data/course-meta/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export { getCourseMetadata } from './api';
|
||||
export {
|
||||
reducer,
|
||||
courseMetadataShape,
|
||||
} from './slice';
|
||||
export { fetchCourseMetadata } from './thunks';
|
||||
93
src/data/course-meta/slice.js
Normal file
93
src/data/course-meta/slice.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/* 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,
|
||||
})),
|
||||
});
|
||||
21
src/data/course-meta/thunks.js
Normal file
21
src/data/course-meta/thunks.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* 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));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
fetchCourse,
|
||||
fetchSequence,
|
||||
} from './thunks';
|
||||
|
||||
export { reducer } from './slice';
|
||||
@@ -1,61 +0,0 @@
|
||||
/* 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;
|
||||
@@ -1,107 +0,0 @@
|
||||
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 && 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 }));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import Alert from '../user-messages/Alert';
|
||||
import messages from './messages';
|
||||
|
||||
function StaffEnrollmentAlert({ intl }) {
|
||||
return (
|
||||
<Alert type="info" dismissible>
|
||||
{intl.formatMessage(messages['learning.staff.enrollment.alert'])}
|
||||
{' '}
|
||||
<a href={`${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`}>
|
||||
{intl.formatMessage(messages['learning.enrollment.enroll.now'])}
|
||||
</a>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
StaffEnrollmentAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(StaffEnrollmentAlert);
|
||||
@@ -1,36 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useContext, useState, useEffect } from 'react';
|
||||
import UserMessagesContext from '../user-messages/UserMessagesContext';
|
||||
import { useModel } from '../model-store';
|
||||
|
||||
export function useEnrollmentAlert(courseId) {
|
||||
const course = useModel('courses', courseId);
|
||||
const { add, remove } = useContext(UserMessagesContext);
|
||||
const [alertId, setAlertId] = useState(null);
|
||||
const isEnrolled = course && course.isEnrolled;
|
||||
useEffect(() => {
|
||||
if (course && course.isEnrolled !== undefined) {
|
||||
if (!course.isEnrolled && alertId === null) {
|
||||
if (course.isStaff) {
|
||||
setAlertId(add({
|
||||
code: 'clientStaffEnrollmentAlert',
|
||||
topic: 'course',
|
||||
}));
|
||||
} else {
|
||||
setAlertId(add({
|
||||
code: 'clientEnrollmentAlert',
|
||||
topic: 'course',
|
||||
}));
|
||||
}
|
||||
} else if (course.isEnrolled && alertId !== null) {
|
||||
remove(alertId);
|
||||
setAlertId(null);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (alertId !== null) {
|
||||
remove(alertId);
|
||||
}
|
||||
};
|
||||
}, [course, isEnrolled]);
|
||||
}
|
||||
@@ -1,3 +1 @@
|
||||
export { default as EnrollmentAlert } from './EnrollmentAlert';
|
||||
export { default as StaffEnrollmentAlert } from './StaffEnrollmentAlert';
|
||||
export { useEnrollmentAlert } from './hooks';
|
||||
export { default } from './EnrollmentAlert';
|
||||
|
||||
@@ -6,11 +6,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'You must be enrolled in the course to see course content.',
|
||||
description: 'Message shown to indicate that a user needs to enroll in a course prior to viewing the course content. Shown as part of an alert, along with a link to enroll.',
|
||||
},
|
||||
'learning.staff.enrollment.alert': {
|
||||
id: 'learning.staff.enrollment.alert',
|
||||
defaultMessage: 'You are viewing this course as staff, and are not enrolled.',
|
||||
description: 'Message shown to indicate that a user is not enrolled, but is able to view a course anyway because they are staff. Shown as part of an alert, along with a link to enroll.',
|
||||
},
|
||||
'learning.enrollment.enroll.now': {
|
||||
id: 'learning.enrollment.enroll.now',
|
||||
defaultMessage: 'Enroll Now',
|
||||
|
||||
50
src/hooks.js
Normal file
50
src/hooks.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useContext, useState, useEffect } from 'react';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import UserMessagesContext from './user-messages/UserMessagesContext';
|
||||
|
||||
export function useLogistrationAlert() {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const { add, remove } = useContext(UserMessagesContext);
|
||||
const [alertId, setAlertId] = useState(null);
|
||||
useEffect(() => {
|
||||
if (authenticatedUser === null) {
|
||||
setAlertId(add({
|
||||
code: 'clientLogistrationAlert',
|
||||
dismissible: false,
|
||||
type: 'error',
|
||||
topic: 'course',
|
||||
}));
|
||||
} else if (alertId !== null) {
|
||||
remove(alertId);
|
||||
setAlertId(null);
|
||||
}
|
||||
return () => {
|
||||
if (alertId !== null) {
|
||||
remove(alertId);
|
||||
}
|
||||
};
|
||||
}, [authenticatedUser]);
|
||||
}
|
||||
|
||||
export function useEnrollmentAlert(isEnrolled) {
|
||||
const { add, remove } = useContext(UserMessagesContext);
|
||||
const [alertId, setAlertId] = useState(null);
|
||||
useEffect(() => {
|
||||
if (!isEnrolled) {
|
||||
setAlertId(add({
|
||||
code: 'clientEnrollmentAlert',
|
||||
dismissible: false,
|
||||
type: 'error',
|
||||
topic: 'course',
|
||||
}));
|
||||
} else if (alertId !== null) {
|
||||
remove(alertId);
|
||||
setAlertId(null);
|
||||
}
|
||||
return () => {
|
||||
if (alertId !== null) {
|
||||
remove(alertId);
|
||||
}
|
||||
};
|
||||
}, [isEnrolled]);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import { Route, Switch, Link } from 'react-router-dom';
|
||||
|
||||
import { messages as headerMessages } from '@edx/frontend-component-header';
|
||||
import Footer, { messages as footerMessages } from '@edx/frontend-component-footer';
|
||||
@@ -17,26 +17,37 @@ import UserMessagesProvider from './user-messages/UserMessagesProvider';
|
||||
|
||||
import './index.scss';
|
||||
import './assets/favicon.ico';
|
||||
import CoursewareContainer from './courseware';
|
||||
import CourseHomeContainer from './course-home';
|
||||
import CoursewareRedirect from './CoursewareRedirect';
|
||||
import CourseContainer from './courseware/CourseContainer';
|
||||
import OutlineContainer from './outline/OutlineContainer';
|
||||
|
||||
import store from './store';
|
||||
|
||||
function courseLinks() {
|
||||
// TODO: We should remove these links before we go live for learners.
|
||||
return (
|
||||
<main className="m-3">
|
||||
<ul>
|
||||
<li><Link to="/course/course-v1:edX+DemoX+Demo_Course">Visit Demo Course</Link></li>
|
||||
<li><Link to="/course/course-v1:UBCx+Water201x_2+2T2015">Visit Staging Course</Link></li>
|
||||
</ul>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
subscribe(APP_READY, () => {
|
||||
ReactDOM.render(
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Switch>
|
||||
<Route path="/learning/redirect" component={CoursewareRedirect} />
|
||||
<Route path="/learning/course/:courseId/home" component={CourseHomeContainer} />
|
||||
<Route exact path="/" render={courseLinks} />
|
||||
<Route path="/outline/:courseUsageKey" component={OutlineContainer} />
|
||||
<Route
|
||||
path={[
|
||||
'/learning/course/:courseId/:sequenceId/:unitId',
|
||||
'/learning/course/:courseId/:sequenceId',
|
||||
'/learning/course/:courseId',
|
||||
'/course/:courseUsageKey/:sequenceId/:unitId',
|
||||
'/course/:courseUsageKey/:sequenceId',
|
||||
'/course/:courseUsageKey',
|
||||
]}
|
||||
component={CoursewareContainer}
|
||||
component={CourseContainer}
|
||||
/>
|
||||
</Switch>
|
||||
<Footer />
|
||||
@@ -51,9 +62,6 @@ subscribe(APP_INIT_ERROR, (error) => {
|
||||
});
|
||||
|
||||
initialize({
|
||||
// TODO: Remove this once the course blocks api supports unauthenticated
|
||||
// access and we are prepared to support public courses in this app.
|
||||
requireAuthenticatedUser: true,
|
||||
messages: [
|
||||
appMessages,
|
||||
headerMessages,
|
||||
|
||||
207
src/index.scss
207
src/index.scss
@@ -16,14 +16,6 @@ $primary: #1176B2;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Fix .container-fluid for mobile in paragon
|
||||
.container-fluid {
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, 'sm')) {
|
||||
padding-left: $grid-gutter-width/2;
|
||||
padding-right: $grid-gutter-width/2;
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -53,100 +45,57 @@ $primary: #1176B2;
|
||||
}
|
||||
}
|
||||
|
||||
.course-header {
|
||||
min-width: 0;
|
||||
.course-title-lockup {
|
||||
min-width: 0;
|
||||
span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
.user-dropdown {
|
||||
.btn {
|
||||
height: 3rem;
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, 'sm')) {
|
||||
padding: 0 .5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.course-tabs-navigation {
|
||||
margin-top: 4px;
|
||||
border-bottom: solid 1px #EAEAEA;
|
||||
}
|
||||
|
||||
.nav-underline-tabs {
|
||||
margin: 0 0 -1px;
|
||||
margin: 0;
|
||||
.nav-link {
|
||||
border-bottom: 4px solid transparent;
|
||||
border-top: 4px solid transparent;
|
||||
color: theme-color('gray', 400);
|
||||
|
||||
// temporary until we can remove .btn class from dropdowns
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
border-radius: 0;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.active {
|
||||
font-weight: $font-weight-normal;
|
||||
color: theme-color('primary', 500);
|
||||
border-bottom-color: theme-color('primary', 500);
|
||||
border-color: theme-color('primary', 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sequence-container {
|
||||
.course-content-container {
|
||||
border: solid 1px #EAEAEA;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
margin-bottom: 4rem;
|
||||
// On mobile, the unit container will be responsible
|
||||
// for container padding.
|
||||
@media (min-width: map-get($grid-breakpoints, 'sm')) {
|
||||
max-width: 1440px;
|
||||
width: 100%;
|
||||
padding: 0 $grid-gutter-width;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.sequence {
|
||||
@media (min-width: map-get($grid-breakpoints, 'sm')) {
|
||||
border: solid 1px #EAEAEA;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.sequence-navigation {
|
||||
display: flex;
|
||||
@media (min-width: map-get($grid-breakpoints, 'sm')) {
|
||||
margin: -1px -1px 0;
|
||||
}
|
||||
|
||||
margin: -1px -1px 0;
|
||||
.btn {
|
||||
flex-grow: 1;
|
||||
display: inline-flex;
|
||||
display: block;
|
||||
border-radius: 0;
|
||||
border: solid 1px #EAEAEA;
|
||||
border-left-width: 0;
|
||||
margin-left: -1px;
|
||||
position: relative;
|
||||
font-weight: 400;
|
||||
padding: 0 .375rem;
|
||||
height: 3rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-basis: 80%;
|
||||
padding: .5rem;
|
||||
color: theme-color('gray', 400);
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.active {
|
||||
color: theme-color('gray', 700);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -165,145 +114,59 @@ $primary: #1176B2;
|
||||
background-color: #EEF7E5;
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-left-width: 0;
|
||||
}
|
||||
&:last-child {
|
||||
border-right-width: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sequence-navigation-tabs-container {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
flex-basis: 100%;
|
||||
display: flex;
|
||||
// min-width 0 prevents the flex item from overflowing the parent container
|
||||
// https://dev.to/martyhimmel/quick-tip-to-stop-flexbox-from-overflowing-peb
|
||||
min-width: 0;
|
||||
}
|
||||
.sequence-navigation-tabs {
|
||||
.btn {
|
||||
flex-basis: 100%;
|
||||
min-width: 4rem;
|
||||
}
|
||||
}
|
||||
.sequence-navigation-dropdown {
|
||||
.dropdown-menu .btn {
|
||||
flex-basis: 100%;
|
||||
min-width: 4rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
border-right: 0;
|
||||
& + .btn {
|
||||
border-top: 0;
|
||||
}
|
||||
.unit-title {
|
||||
flex-grow: 1;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
margin: 0 1rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
&.active {
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: -1px;
|
||||
top: 0;
|
||||
right: auto;
|
||||
width: 2px;
|
||||
height: auto;
|
||||
background: $primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.previous-btn, .next-btn {
|
||||
min-width: 4rem;
|
||||
flex-basis: 10em;
|
||||
min-width: 9em;
|
||||
color: theme-color('gray', 700);
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, 'sm')) {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
span {
|
||||
@include sr-only();
|
||||
}
|
||||
}
|
||||
@media (min-width: map-get($grid-breakpoints, 'sm')) {
|
||||
min-width: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
.previous-btn {
|
||||
border-left-width: 0;
|
||||
margin-left: 0;
|
||||
@media (min-width: map-get($grid-breakpoints, 'sm')) {
|
||||
border-left-width: 1px;
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
border-left-width: 1px;
|
||||
border-right-width: 0;
|
||||
@media (min-width: map-get($grid-breakpoints, 'sm')) {
|
||||
border-top-right-radius: 4px;
|
||||
border-right-width: 1px;
|
||||
}
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.unit-container {
|
||||
padding: 0 $grid-gutter-width 2rem;
|
||||
.unit-content-container {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
max-width: 1024px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
@media (min-width: 830px) {
|
||||
padding-left: 40px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.unit-iframe-wrapper {
|
||||
margin: 0 -20px 2rem;
|
||||
@media (min-width: 830px) {
|
||||
margin: 0 -40px 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
#unit-iframe {
|
||||
width: 100%;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.unit-navigation {
|
||||
.below-unit-navigation {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
padding-left: 6rem;
|
||||
padding-right: 6rem;
|
||||
.previous-button,
|
||||
.next-button {
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
&:focus:before {
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
.next-button {
|
||||
flex-basis: 75%;
|
||||
}
|
||||
.previous-button {
|
||||
flex-basis: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
#unit-iframe {
|
||||
max-width: 1024px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useContext, useState, useEffect } from 'react';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import UserMessagesContext from '../user-messages/UserMessagesContext';
|
||||
|
||||
export function useLogistrationAlert() {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const { add, remove } = useContext(UserMessagesContext);
|
||||
const [alertId, setAlertId] = useState(null);
|
||||
useEffect(() => {
|
||||
if (authenticatedUser === null && alertId === null) {
|
||||
setAlertId(add({
|
||||
code: 'clientLogistrationAlert',
|
||||
dismissible: false,
|
||||
type: 'error',
|
||||
topic: 'course',
|
||||
}));
|
||||
} else if (authenticatedUser !== null && alertId !== null) {
|
||||
remove(alertId);
|
||||
setAlertId(null);
|
||||
}
|
||||
return () => {
|
||||
if (alertId !== null) {
|
||||
remove(alertId);
|
||||
}
|
||||
};
|
||||
}, [authenticatedUser]);
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export { default } from './LogistrationAlert';
|
||||
export { useLogistrationAlert } from './hooks';
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useSelector, shallowEqual } from 'react-redux';
|
||||
|
||||
export function useModel(type, id) {
|
||||
return useSelector(
|
||||
state => (state.models[type] !== undefined ? state.models[type][id] : undefined),
|
||||
shallowEqual,
|
||||
);
|
||||
}
|
||||
|
||||
export function useModels(type, ids) {
|
||||
return useSelector(
|
||||
state => ids.map(
|
||||
id => (state.models[type] !== undefined ? state.models[type][id] : undefined),
|
||||
),
|
||||
shallowEqual,
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export {
|
||||
reducer,
|
||||
addModel,
|
||||
addModels,
|
||||
addModelsMap,
|
||||
updateModel,
|
||||
updateModels,
|
||||
updateModelsMap,
|
||||
removeModel,
|
||||
removeModels,
|
||||
} from './slice';
|
||||
|
||||
export {
|
||||
useModel,
|
||||
useModels,
|
||||
} from './hooks';
|
||||
@@ -1,77 +0,0 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
function add(state, modelType, model) {
|
||||
const { id } = model;
|
||||
if (state[modelType] === undefined) {
|
||||
state[modelType] = {};
|
||||
}
|
||||
state[modelType][id] = model;
|
||||
}
|
||||
|
||||
function update(state, modelType, model) {
|
||||
if (state[modelType] === undefined) {
|
||||
state[modelType] = {};
|
||||
}
|
||||
state[modelType][model.id] = { ...state[modelType][model.id], ...model };
|
||||
}
|
||||
|
||||
function remove(state, modelType, id) {
|
||||
if (state[modelType] === undefined) {
|
||||
state[modelType] = {};
|
||||
}
|
||||
|
||||
delete state[modelType][id];
|
||||
}
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'models',
|
||||
initialState: {},
|
||||
reducers: {
|
||||
addModel: (state, { payload }) => {
|
||||
const { modelType, model } = payload;
|
||||
add(state, modelType, model);
|
||||
},
|
||||
addModels: (state, { payload }) => {
|
||||
const { modelType, models } = payload;
|
||||
models.forEach(model => add(state, modelType, model));
|
||||
},
|
||||
addModelsMap: (state, { payload }) => {
|
||||
const { modelType, modelsMap } = payload;
|
||||
Object.values(modelsMap).forEach(model => add(state, modelType, model));
|
||||
},
|
||||
updateModel: (state, { payload }) => {
|
||||
const { modelType, model } = payload;
|
||||
update(state, modelType, model);
|
||||
},
|
||||
updateModels: (state, { payload }) => {
|
||||
const { modelType, models } = payload;
|
||||
models.forEach(model => update(state, modelType, model));
|
||||
},
|
||||
updateModelsMap: (state, { payload }) => {
|
||||
const { modelType, modelsMap } = payload;
|
||||
Object.values(modelsMap).forEach(model => update(state, modelType, model));
|
||||
},
|
||||
removeModel: (state, { payload }) => {
|
||||
const { modelType, id } = payload;
|
||||
remove(state, modelType, id);
|
||||
},
|
||||
removeModels: (state, { payload }) => {
|
||||
const { modelType, ids } = payload;
|
||||
ids.forEach(id => remove(state, modelType, id));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
addModel,
|
||||
addModels,
|
||||
addModelsMap,
|
||||
updateModel,
|
||||
updateModels,
|
||||
updateModelsMap,
|
||||
removeModel,
|
||||
removeModels,
|
||||
} = slice.actions;
|
||||
|
||||
export const { reducer } = slice;
|
||||
@@ -4,11 +4,10 @@ import { Collapsible } from '@edx/paragon';
|
||||
import { faChevronRight, faChevronDown } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import SequenceLink from './SequenceLink';
|
||||
import { useModel } from '../model-store';
|
||||
import { courseBlocksShape } from '../data/course-blocks';
|
||||
|
||||
export default function Section({ id, courseId }) {
|
||||
const section = useModel('sections', id);
|
||||
const { title, sequenceIds } = section;
|
||||
export default function Chapter({ id, courseUsageKey, models }) {
|
||||
const { displayName, children } = models[id];
|
||||
return (
|
||||
<Collapsible.Advanced className="collapsible-card mb-2">
|
||||
<Collapsible.Trigger className="collapsible-trigger d-flex align-items-start">
|
||||
@@ -22,15 +21,16 @@ export default function Section({ id, courseId }) {
|
||||
<FontAwesomeIcon icon={faChevronDown} />
|
||||
</div>
|
||||
</Collapsible.Visible>
|
||||
<div className="ml-2 flex-grow-1">{title}</div>
|
||||
<div className="ml-2 flex-grow-1">{displayName}</div>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Body className="collapsible-body">
|
||||
{sequenceIds.map((sequenceId) => (
|
||||
{children.map((sequenceId) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequenceId}
|
||||
courseId={courseId}
|
||||
courseUsageKey={courseUsageKey}
|
||||
models={models}
|
||||
/>
|
||||
))}
|
||||
</Collapsible.Body>
|
||||
@@ -38,7 +38,8 @@ export default function Section({ id, courseId }) {
|
||||
);
|
||||
}
|
||||
|
||||
Section.propTypes = {
|
||||
Chapter.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
courseUsageKey: PropTypes.string.isRequired,
|
||||
models: courseBlocksShape.isRequired,
|
||||
};
|
||||
115
src/outline/Outline.jsx
Normal file
115
src/outline/Outline.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import AlertList from '../user-messages/AlertList';
|
||||
import CourseHeader from '../courseware/course/CourseHeader';
|
||||
import CourseTabsNavigation from '../courseware/course/CourseTabsNavigation';
|
||||
import CourseDates from './CourseDates';
|
||||
import { useLogistrationAlert, useEnrollmentAlert } from '../hooks';
|
||||
import Chapter from './Chapter';
|
||||
import { courseBlocksShape } from '../data/course-blocks';
|
||||
|
||||
const EnrollmentAlert = React.lazy(() => import('../enrollment-alert'));
|
||||
const LogistrationAlert = React.lazy(() => import('../logistration-alert'));
|
||||
|
||||
export default function Outline({
|
||||
courseOrg,
|
||||
courseNumber,
|
||||
courseName,
|
||||
courseUsageKey,
|
||||
courseId,
|
||||
models,
|
||||
tabs,
|
||||
start,
|
||||
end,
|
||||
enrollmentStart,
|
||||
enrollmentEnd,
|
||||
enrollmentMode,
|
||||
isEnrolled,
|
||||
}) {
|
||||
const course = models[courseId];
|
||||
|
||||
useLogistrationAlert();
|
||||
useEnrollmentAlert(isEnrolled);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CourseHeader
|
||||
courseOrg={courseOrg}
|
||||
courseNumber={courseNumber}
|
||||
courseName={courseName}
|
||||
/>
|
||||
<main className="d-flex flex-column flex-grow-1">
|
||||
<div className="container-fluid">
|
||||
<CourseTabsNavigation tabs={tabs} className="mb-3" activeTabSlug="courseware" />
|
||||
<AlertList
|
||||
topic="outline"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
clientEnrollmentAlert: EnrollmentAlert,
|
||||
clientLogistrationAlert: LogistrationAlert,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow-1">
|
||||
<div className="container-fluid">
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<h2>{courseName}</h2>
|
||||
<Button className="btn-primary" type="button">Resume Course</Button>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col col-8">
|
||||
{course.children.map((chapterId) => (
|
||||
<Chapter
|
||||
key={chapterId}
|
||||
id={chapterId}
|
||||
courseUsageKey={courseUsageKey}
|
||||
models={models}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="col col-4">
|
||||
<CourseDates
|
||||
start={start}
|
||||
end={end}
|
||||
enrollmentStart={enrollmentStart}
|
||||
enrollmentEnd={enrollmentEnd}
|
||||
enrollmentMode={enrollmentMode}
|
||||
isEnrolled={isEnrolled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Outline.propTypes = {
|
||||
courseOrg: PropTypes.string.isRequired,
|
||||
courseNumber: PropTypes.string.isRequired,
|
||||
courseName: PropTypes.string.isRequired,
|
||||
courseUsageKey: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
start: PropTypes.string.isRequired,
|
||||
end: PropTypes.string.isRequired,
|
||||
enrollmentStart: PropTypes.string.isRequired,
|
||||
enrollmentEnd: PropTypes.string.isRequired,
|
||||
enrollmentMode: PropTypes.string.isRequired,
|
||||
isEnrolled: PropTypes.bool,
|
||||
models: courseBlocksShape.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,
|
||||
};
|
||||
|
||||
Outline.defaultProps = {
|
||||
isEnrolled: false,
|
||||
};
|
||||
85
src/outline/OutlineContainer.jsx
Normal file
85
src/outline/OutlineContainer.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { fetchCourseMetadata, courseMetadataShape } from '../data/course-meta';
|
||||
import { fetchCourseBlocks, courseBlocksShape } from '../data/course-blocks';
|
||||
|
||||
import messages from '../courseware/messages';
|
||||
import PageLoading from '../PageLoading';
|
||||
import Outline from './Outline';
|
||||
|
||||
function OutlineContainer(props) {
|
||||
const {
|
||||
intl,
|
||||
match,
|
||||
courseId,
|
||||
blocks: models,
|
||||
metadata,
|
||||
} = props;
|
||||
const { courseUsageKey } = match.params;
|
||||
|
||||
useEffect(() => {
|
||||
props.fetchCourseMetadata(courseUsageKey);
|
||||
props.fetchCourseBlocks(courseUsageKey);
|
||||
}, [courseUsageKey]);
|
||||
|
||||
const ready = metadata.fetchState === 'loaded' && courseId;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ready ? (
|
||||
<Outline
|
||||
courseOrg={metadata.org}
|
||||
courseNumber={metadata.number}
|
||||
courseName={metadata.name}
|
||||
courseUsageKey={courseUsageKey}
|
||||
courseId={courseId}
|
||||
start={metadata.start}
|
||||
end={metadata.end}
|
||||
enrollmentStart={metadata.enrollmentStart}
|
||||
enrollmentEnd={metadata.enrollmentEnd}
|
||||
enrollmentMode={metadata.enrollmentMode}
|
||||
isEnrolled={metadata.isEnrolled}
|
||||
models={models}
|
||||
tabs={metadata.tabs}
|
||||
/>
|
||||
) : (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
OutlineContainer.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string,
|
||||
blocks: courseBlocksShape,
|
||||
metadata: courseMetadataShape,
|
||||
fetchCourseMetadata: PropTypes.func.isRequired,
|
||||
fetchCourseBlocks: PropTypes.func.isRequired,
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
courseUsageKey: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
OutlineContainer.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(OutlineContainer));
|
||||
19
src/outline/SequenceLink.jsx
Normal file
19
src/outline/SequenceLink.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { courseBlocksShape } from '../data/course-blocks';
|
||||
|
||||
export default function SequenceLink({ id, courseUsageKey, models }) {
|
||||
const sequence = models[id];
|
||||
return (
|
||||
<div className="ml-4">
|
||||
<Link to={`/course/${courseUsageKey}/${id}`}>{sequence.displayName}</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SequenceLink.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
courseUsageKey: PropTypes.string.isRequired,
|
||||
models: courseBlocksShape.isRequired,
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { reducer as coursewareReducer } from './data';
|
||||
import { reducer as modelsReducer } from './model-store';
|
||||
import { reducer as courseReducer } from './data/course-meta';
|
||||
import { reducer as courseBlocksReducer } from './data/course-blocks';
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
models: modelsReducer,
|
||||
courseware: coursewareReducer,
|
||||
courseMeta: courseReducer,
|
||||
courseBlocks: courseBlocksReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import classNames from 'classnames';
|
||||
import useIndexOfLastVisibleChild from './useIndexOfLastVisibleChild';
|
||||
|
||||
export default function Tabs({ children, className, ...attrs }) {
|
||||
const [
|
||||
indexOfLastVisibleChild,
|
||||
containerElementRef,
|
||||
invisibleStyle,
|
||||
overflowElementRef,
|
||||
] = useIndexOfLastVisibleChild();
|
||||
|
||||
const tabChildren = useMemo(() => {
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
const indexOfOverflowStart = indexOfLastVisibleChild + 1;
|
||||
|
||||
// All tabs will be rendered. Those that would overflow are set to invisible.
|
||||
const wrappedChildren = childrenArray.map((child, index) => React.cloneElement(child, {
|
||||
style: index > indexOfLastVisibleChild ? invisibleStyle : null,
|
||||
}));
|
||||
|
||||
// Build the list of items to put in the overflow menu
|
||||
const overflowChildren = childrenArray.slice(indexOfOverflowStart)
|
||||
.map(overflowChild => React.cloneElement(overflowChild, { className: 'dropdown-item' }));
|
||||
|
||||
// Insert the overflow menu at the cut off index (even if it will be hidden
|
||||
// it so it can be part of measurements)
|
||||
wrappedChildren.splice(indexOfOverflowStart, 0, (
|
||||
<div
|
||||
className="nav-item flex-shrink-0"
|
||||
style={indexOfOverflowStart >= React.Children.count(children) ? invisibleStyle : null}
|
||||
ref={overflowElementRef}
|
||||
key="overflow"
|
||||
>
|
||||
<Dropdown>
|
||||
<Dropdown.Button className="nav-link font-weight-normal">
|
||||
<FormattedMessage
|
||||
id="learn.course.tabs.navigation.overflow.menu"
|
||||
description="The title of the overflow menu for course tabs"
|
||||
defaultMessage="More..."
|
||||
/>
|
||||
</Dropdown.Button>
|
||||
<Dropdown.Menu className="dropdown-menu-right">{overflowChildren}</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
));
|
||||
return wrappedChildren;
|
||||
}, [children, indexOfLastVisibleChild]);
|
||||
|
||||
return (
|
||||
<nav
|
||||
{...attrs}
|
||||
className={classNames('nav flex-nowrap', className)}
|
||||
ref={containerElementRef}
|
||||
>
|
||||
{tabChildren}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
Tabs.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
Tabs.defaultProps = {
|
||||
children: null,
|
||||
className: undefined,
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import useWindowSize from './useWindowSize';
|
||||
|
||||
const invisibleStyle = {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
visibility: 'hidden',
|
||||
};
|
||||
|
||||
/**
|
||||
* This hook will find the index of the last child of a containing element
|
||||
* that fits within its bounding rectangle. This is done by summing the widths
|
||||
* of the children until they exceed the width of the container.
|
||||
*
|
||||
* The hook returns an array containing:
|
||||
* [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef]
|
||||
*
|
||||
* indexOfLastVisibleChild - the index of the last visible child
|
||||
* containerElementRef - a ref to be added to the containing html node
|
||||
* invisibleStyle - a set of styles to be applied to child of the containing node
|
||||
* if it needs to be hidden. These styles remove the element visually, from
|
||||
* screen readers, and from normal layout flow. But, importantly, these styles
|
||||
* preserve the width of the element, so that future width calculations will
|
||||
* still be accurate.
|
||||
* overflowElementRef - a ref to be added to an html node inside the container
|
||||
* that is likely to be used to contain a "More" type dropdown or other
|
||||
* mechanism to reveal hidden children. The width of this element is always
|
||||
* included when determining which children will fit or not. Usage of this ref
|
||||
* is optional.
|
||||
*/
|
||||
export default function useIndexOfLastVisibleChild() {
|
||||
const containerElementRef = useRef(null);
|
||||
const overflowElementRef = useRef(null);
|
||||
const containingRectRef = useRef({});
|
||||
const [indexOfLastVisibleChild, setIndexOfLastVisibleChild] = useState(-1);
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const containingRect = containerElementRef.current.getBoundingClientRect();
|
||||
|
||||
// No-op if the width is unchanged.
|
||||
// (Assumes tabs themselves don't change count or width).
|
||||
if (!containingRect.width === containingRectRef.current.width) {
|
||||
return;
|
||||
}
|
||||
// Update for future comparison
|
||||
containingRectRef.current = containingRect;
|
||||
|
||||
// Get array of child nodes from NodeList form
|
||||
const childNodesArr = Array.prototype.slice.call(containerElementRef.current.children);
|
||||
const { nextIndexOfLastVisibleChild } = childNodesArr
|
||||
// filter out the overflow element
|
||||
.filter(childNode => childNode !== overflowElementRef.current)
|
||||
// sum the widths to find the last visible element's index
|
||||
.reduce((acc, childNode, index) => {
|
||||
// use floor to prevent rounding errors
|
||||
acc.sumWidth += Math.floor(childNode.getBoundingClientRect().width);
|
||||
if (acc.sumWidth <= containingRect.width) {
|
||||
acc.nextIndexOfLastVisibleChild = index;
|
||||
}
|
||||
return acc;
|
||||
}, {
|
||||
// Include the overflow element's width to begin with. Doing this means
|
||||
// sometimes we'll show a dropdown with one item in it when it would fit,
|
||||
// but allowing this case dramatically simplifies the calculations we need
|
||||
// to do above.
|
||||
sumWidth: overflowElementRef.current ? overflowElementRef.current.getBoundingClientRect().width : 0,
|
||||
nextIndexOfLastVisibleChild: -1,
|
||||
});
|
||||
|
||||
|
||||
setIndexOfLastVisibleChild(nextIndexOfLastVisibleChild);
|
||||
}, [windowSize, containerElementRef.current]);
|
||||
|
||||
return [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user