Compare commits
159 Commits
kdmccormic
...
ddumesnil/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75eaf6611d | ||
|
|
9cbe0b7c8b | ||
|
|
c83389e7c5 | ||
|
|
37d56b4197 | ||
|
|
01f69e2273 | ||
|
|
c5ada7e974 | ||
|
|
450d1c1861 | ||
|
|
aa10b3f600 | ||
|
|
b65bd0ff44 | ||
|
|
86d28136de | ||
|
|
707fcc2aa1 | ||
|
|
caabf6a54c | ||
|
|
d910d09e00 | ||
|
|
de53ed9258 | ||
|
|
81188ae30f | ||
|
|
f5d361661f | ||
|
|
c298bc1dbf | ||
|
|
0ad80a63cf | ||
|
|
bc76adf8eb | ||
|
|
cc7142e5c1 | ||
|
|
a975b8ae70 | ||
|
|
3b34a87391 | ||
|
|
453f56c7c8 | ||
|
|
a131a9f9fb | ||
|
|
d5f9af1954 | ||
|
|
a44d2633a1 | ||
|
|
de2a46eb93 | ||
|
|
9a315aa29d | ||
|
|
ad74b2295b | ||
|
|
c8961d3777 | ||
|
|
c7c401e385 | ||
|
|
9e0f192ae7 | ||
|
|
4667535c0c | ||
|
|
a1646c5793 | ||
|
|
0e6f64081d | ||
|
|
8e4f0535a7 | ||
|
|
6f14f01dc2 | ||
|
|
6233f16812 | ||
|
|
a60480ec52 | ||
|
|
eba9d9a44d | ||
|
|
8cd3d6501f | ||
|
|
76fc6d64f2 | ||
|
|
314b82d0b2 | ||
|
|
e17b66851e | ||
|
|
9accacd019 | ||
|
|
a9cec0102b | ||
|
|
e4d6d37c4e | ||
|
|
7ad501d73b | ||
|
|
f1d43b18d6 | ||
|
|
d408986682 | ||
|
|
8d7fbb5bd8 | ||
|
|
7c046870e3 | ||
|
|
0c5fd44d13 | ||
|
|
f3d23abe84 | ||
|
|
6a44d018d8 | ||
|
|
3362047bcc | ||
|
|
c1bf77efa4 | ||
|
|
e6443ae3bd | ||
|
|
6f7ec81197 | ||
|
|
8719fad091 | ||
|
|
ec7f532bc9 | ||
|
|
0c8389e244 | ||
|
|
cd8f3072e2 | ||
|
|
b048ca8187 | ||
|
|
71482f1ec7 | ||
|
|
7a108728c0 | ||
|
|
d7f41fd02a | ||
|
|
0e7bccef0b | ||
|
|
5ac49610da | ||
|
|
175675da55 | ||
|
|
f715fd5ed6 | ||
|
|
cdab8959ca | ||
|
|
be0ee18519 | ||
|
|
c96cd87967 | ||
|
|
854020dd67 | ||
|
|
d320c6b5bc | ||
|
|
81c6b401fd | ||
|
|
16bd20e0e8 | ||
|
|
23ea255674 | ||
|
|
dec5340bf3 | ||
|
|
086b5d8986 | ||
|
|
b940901400 | ||
|
|
afb4b77250 | ||
|
|
bc30b20b0d | ||
|
|
3e14b17271 | ||
|
|
c0d0895630 | ||
|
|
c2f4ba3ad0 | ||
|
|
eac9bf9c92 | ||
|
|
5332be8e65 | ||
|
|
e04f588d1f | ||
|
|
fe013f57c5 | ||
|
|
7df50264cf | ||
|
|
73c74119f0 | ||
|
|
a6edc9132f | ||
|
|
8b34f8c792 | ||
|
|
534b9b205f | ||
|
|
1471abe7dd | ||
|
|
d55c8b134c | ||
|
|
6c052a2661 | ||
|
|
ac47454b14 | ||
|
|
a18adc4112 | ||
|
|
6cdd075243 | ||
|
|
eb8c97ee86 | ||
|
|
24051232af | ||
|
|
dee5128448 | ||
|
|
5ffc1bc599 | ||
|
|
bf0d3b1565 | ||
|
|
8df4654cf1 | ||
|
|
973f3d68aa | ||
|
|
b51809fa50 | ||
|
|
253836fa9f | ||
|
|
65173e9f93 | ||
|
|
a8d01c423d | ||
|
|
025f37cd21 | ||
|
|
964bde180a | ||
|
|
209a64c29b | ||
|
|
bdb1afe990 | ||
|
|
7487d8d32f | ||
|
|
e101b41c08 | ||
|
|
2f01e8a646 | ||
|
|
589db9356e | ||
|
|
e2f37ff20e | ||
|
|
ab544b5d2b | ||
|
|
7bef14c329 | ||
|
|
5cb11189a7 | ||
|
|
fd951fb18a | ||
|
|
b2fa93af13 | ||
|
|
93ccdf829b | ||
|
|
65ab77bed3 | ||
|
|
efba1c1f5a | ||
|
|
3c53c4af4e | ||
|
|
2b27f0774d | ||
|
|
97f335be62 | ||
|
|
6c7af3817b | ||
|
|
a7932ed730 | ||
|
|
29b234e2f0 | ||
|
|
a718c67f36 | ||
|
|
cc5e5ecc00 | ||
|
|
7df95378d6 | ||
|
|
18426dd313 | ||
|
|
a1eee2d662 | ||
|
|
7dfb01a397 | ||
|
|
d58a81bf19 | ||
|
|
bd0ab5b6c9 | ||
|
|
5185f986df | ||
|
|
d3b22bc879 | ||
|
|
36526def67 | ||
|
|
c510fe1c1d | ||
|
|
ca8afb3294 | ||
|
|
1f4e2cd6f5 | ||
|
|
6d60584596 | ||
|
|
b20a4ed304 | ||
|
|
44f535ba1e | ||
|
|
5f0774b66d | ||
|
|
04a8638d00 | ||
|
|
1cc7dc266b | ||
|
|
a852182a00 | ||
|
|
15c3053e87 | ||
|
|
e2399e30d4 |
4
.env
4
.env
@@ -3,7 +3,9 @@ ACCESS_TOKEN_COOKIE_NAME=null
|
||||
BASE_URL=null
|
||||
CREDENTIALS_BASE_URL=null
|
||||
CSRF_TOKEN_API_PATH=null
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=null
|
||||
ECOMMERCE_BASE_URL=null
|
||||
INSIGHTS_BASE_URL=
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME=null
|
||||
LMS_BASE_URL=null
|
||||
LOGIN_URL=null
|
||||
@@ -13,4 +15,6 @@ ORDER_HISTORY_URL=null
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=null
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME=null
|
||||
TWITTER_URL=null
|
||||
STUDIO_BASE_URL=
|
||||
USER_INFO_COOKIE_NAME=null
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
NODE_ENV='development'
|
||||
PORT=2000
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:2000'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
|
||||
10
.env.test
10
.env.test
@@ -1,15 +1,19 @@
|
||||
NODE_ENV='test'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:1995'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
|
||||
10
.eslintrc.js
10
.eslintrc.js
@@ -1,3 +1,11 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('eslint');
|
||||
module.exports = createConfig('eslint', {
|
||||
overrides: [{
|
||||
files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)", "setupTest.js"],
|
||||
rules: {
|
||||
'import/named': 'off',
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -12,7 +12,10 @@ temp/babel-plugin-react-intl
|
||||
### pyenv ###
|
||||
.python-version
|
||||
|
||||
### Emacs ###
|
||||
### Editors ###
|
||||
*~
|
||||
/temp
|
||||
/.vscode
|
||||
|
||||
# Local package dependencies
|
||||
module.config.js
|
||||
|
||||
@@ -7,7 +7,7 @@ install:
|
||||
script:
|
||||
- make validate-no-uncommitted-package-lock-changes
|
||||
- npm run i18n_extract
|
||||
- npm run lint
|
||||
- npm run lint -- --max-warnings 0
|
||||
- npm run test
|
||||
- npm run build
|
||||
- npm run is-es5
|
||||
|
||||
49
README.rst
49
README.rst
@@ -20,3 +20,52 @@ React app for edX learning.
|
||||
:target: @edx/frontend-app-learning
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-learning.svg
|
||||
:target: @edx/frontend-app-learning
|
||||
|
||||
Development
|
||||
-----------
|
||||
|
||||
Start Devstack
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
To use this application `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
|
||||
|
||||
- Start devstack
|
||||
- Log in (http://localhost:18000/login)
|
||||
|
||||
Start the development server
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
In this project, install requirements and start the development server by running:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
npm install
|
||||
npm start # The server will run on port 1995
|
||||
|
||||
Once the dev server is up, visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
|
||||
|
||||
Local module development
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
To develop locally on modules that are installed into this app, you'll need to create a ``module.config.js``
|
||||
file (which is git-ignored) that defines where to find your local modules, for instance::
|
||||
|
||||
module.exports = {
|
||||
/*
|
||||
Modules you want to use from local source code. Adding a module here means that when this app
|
||||
runs its build, it'll resolve the source from peer directories of this app.
|
||||
|
||||
moduleName: the name you use to import code from the module.
|
||||
dir: The relative path to the module's source code.
|
||||
dist: The sub-directory of the source code where it puts its build artifact. Often "dist", though you
|
||||
may want to use "src" if the module installs React as a peer/dev dependency.
|
||||
*/
|
||||
localModules: [
|
||||
{ moduleName: '@edx/paragon/scss', dir: '../paragon', dist: 'scss' },
|
||||
{ moduleName: '@edx/paragon', dir: '../paragon', dist: 'dist' },
|
||||
{ moduleName: '@edx/frontend-enterprise', dir: '../frontend-enterprise', dist: 'src' },
|
||||
{ moduleName: '@edx/frontend-platform', dir: '../frontend-platform', dist: 'dist' },
|
||||
],
|
||||
};
|
||||
|
||||
See https://github.com/edx/frontend-build#local-module-configuration-for-webpack for more details.
|
||||
|
||||
@@ -4,4 +4,6 @@ Because we have a variety of models in this app (course, section, sequence, unit
|
||||
|
||||
https://redux.js.org/faq/organizing-state#how-do-i-organize-nested-or-duplicate-data-in-my-state
|
||||
|
||||
(As an additional data point, djoy has stored data in this format in multiple projects over the years and found it to be very effective)
|
||||
Different modules of the application maintain individual/lists of IDs that reference data stored in the model store. These are akin to indices in a database, in that they allow you to quickly extract data from the model store without iteration or filtering.
|
||||
|
||||
A common pattern when loading data from an API endpoint is to use the model-store's redux actions (addModel, updateModel, etc.) to load the "models" themselves into the model store by ID, and then dispatch another action to save references elsewhere in the redux store to the data that was just added. When adding courses, sequences, etc., to model-store, we also save the courseId and sequenceId in the 'courseware' part of redux. This means the courseware React Components can extract the data from the model-store quickly by using the courseId as a key: `state.models.courses[state.courseware.courseId]`. For an array, it iterates once over the ID list in order to extract the models from model-store. This iteration is done when React components' re-render, and can be done less often through memoization as necessary.
|
||||
|
||||
24
docs/decisions/0006-thunk-and-api-naming.md
Normal file
24
docs/decisions/0006-thunk-and-api-naming.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Naming API functions and redux thunks
|
||||
|
||||
Because API functions and redux thunks are two parts of a larger process, we've informally settled on some naming conventions for them to help differentiate the type of code we're looking at.
|
||||
|
||||
## API Functions
|
||||
|
||||
This micro-frontend follows a pattern of naming API functions with a prefix for their HTTP verb.
|
||||
|
||||
Examples:
|
||||
|
||||
`getCourseBlocks` - The GET request we make to load course blocks data.
|
||||
`postSequencePosition` - The POST request for saving sequence position.
|
||||
|
||||
## Redux Thunks
|
||||
|
||||
Meanwhile, we use a different set of verbs for redux thunks to differentiate them from the API functions. For instance, we use the `fetch` prefix for loading data (primarily via GET requests), and `save` for sending data back to the server (primarily via POST or PATCH requests)
|
||||
|
||||
Examples:
|
||||
|
||||
`fetchCourse` - The thunk for getting course data across several APIs.
|
||||
`fetchSequence` - The thunk for the process of retrieving sequence data.
|
||||
`saveSequencePosition` - Wraps the POST request for sending sequence position back to the server.
|
||||
|
||||
The verb prefixes for thunks aren't perfect - but they're a little more 'friendly' and semantically meaningful than the HTTP verbs used for APIs. So far we have `fetch`, `save`, `check`, `reset`, etc.
|
||||
@@ -1,11 +1,27 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFiles: [
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
'.*',
|
||||
'src/setupTest.js',
|
||||
'src/i18n',
|
||||
'src/alerts/.*',
|
||||
'src/assets/.*',
|
||||
'src/course-header/.*',
|
||||
'src/courseware/.*',
|
||||
'src/data/.*',
|
||||
'src/generic/.*',
|
||||
'src/i18n/.*',
|
||||
'src/index.jsx',
|
||||
'src/index.scss',
|
||||
'src/instructor-toolbar/.*',
|
||||
'src/setupTest.js',
|
||||
'src/store.js',
|
||||
'src/tab-page/.*',
|
||||
'src/toast/.*',
|
||||
'src/utils.js',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -3,3 +3,8 @@
|
||||
|
||||
oeps: {}
|
||||
owner: edx/platform-core-tnl
|
||||
openedx-release:
|
||||
# The openedx-release key is described in OEP-10:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
|
||||
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
|
||||
ref: master
|
||||
|
||||
16640
package-lock.json
generated
16640
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
70
package.json
70
package.json
@@ -19,11 +19,6 @@
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run lint"
|
||||
}
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/edx/frontend-app-learning#readme",
|
||||
@@ -34,34 +29,45 @@
|
||||
"url": "https://github.com/edx/frontend-app-learning/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/frontend-component-footer": "^10.0.6",
|
||||
"@edx/frontend-component-header": "^2.0.3",
|
||||
"@edx/frontend-platform": "^1.3.1",
|
||||
"@edx/paragon": "^7.2.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.26",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.12.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.12.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.12.0",
|
||||
"@fortawesome/react-fontawesome": "^0.1.8",
|
||||
"@reduxjs/toolkit": "^1.2.3",
|
||||
"classnames": "^2.2.6",
|
||||
"core-js": "^3.6.2",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-redux": "^7.1.3",
|
||||
"react-router": "^5.1.2",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"redux": "^4.0.5",
|
||||
"regenerator-runtime": "^0.13.3"
|
||||
"@edx/frontend-component-footer": "10.0.11",
|
||||
"@edx/frontend-component-header": "2.0.5",
|
||||
"@edx/frontend-enterprise": "4.2.2",
|
||||
"@edx/frontend-platform": "1.5.2",
|
||||
"@edx/paragon": "10.1.1",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.30",
|
||||
"@fortawesome/free-brands-svg-icons": "5.13.1",
|
||||
"@fortawesome/free-regular-svg-icons": "5.13.1",
|
||||
"@fortawesome/free-solid-svg-icons": "5.13.1",
|
||||
"@fortawesome/react-fontawesome": "0.1.11",
|
||||
"@reduxjs/toolkit": "1.3.6",
|
||||
"classnames": "2.2.6",
|
||||
"core-js": "3.6.5",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.13.1",
|
||||
"react-break": "1.3.2",
|
||||
"react-dom": "16.13.1",
|
||||
"react-helmet": "6.0.0",
|
||||
"react-redux": "7.2.1",
|
||||
"react-router": "5.2.0",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-share": "4.2.1",
|
||||
"redux": "4.0.5",
|
||||
"regenerator-runtime": "0.13.7",
|
||||
"reselect": "4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "^3.0.0",
|
||||
"codecov": "^3.6.1",
|
||||
"es-check": "^5.1.0",
|
||||
"glob": "^7.1.6",
|
||||
"husky": "^3.1.0",
|
||||
"jest": "^24.9.0",
|
||||
"reactifex": "^1.1.1"
|
||||
"@edx/frontend-build": "5.2.0",
|
||||
"@testing-library/dom": "7.16.3",
|
||||
"@testing-library/jest-dom": "5.10.1",
|
||||
"@testing-library/react": "10.3.0",
|
||||
"@testing-library/user-event": "12.0.17",
|
||||
"axios-mock-adapter": "1.18.2",
|
||||
"codecov": "3.7.2",
|
||||
"es-check": "5.1.0",
|
||||
"glob": "7.1.6",
|
||||
"husky": "3.1.0",
|
||||
"jest": "24.9.0",
|
||||
"reactifex": "1.1.1",
|
||||
"rosie": "2.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<title>Course | edX</title>
|
||||
<title>Course | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
|
||||
24
src/alerts/access-expiration-alert/AccessExpirationAlert.jsx
Normal file
24
src/alerts/access-expiration-alert/AccessExpirationAlert.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
|
||||
function AccessExpirationAlert({ payload }) {
|
||||
const {
|
||||
rawHtml,
|
||||
} = payload;
|
||||
return rawHtml && (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
AccessExpirationAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
rawHtml: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default AccessExpirationAlert;
|
||||
19
src/alerts/access-expiration-alert/hooks.js
Normal file
19
src/alerts/access-expiration-alert/hooks.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
|
||||
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
|
||||
|
||||
function useAccessExpirationAlert(courseExpiredMessage, topic) {
|
||||
const rawHtml = courseExpiredMessage || null;
|
||||
const isVisible = !!rawHtml; // If it exists, show it.
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientAccessExpirationAlert',
|
||||
payload: useMemo(() => ({ rawHtml }), [rawHtml]),
|
||||
topic,
|
||||
});
|
||||
|
||||
return { clientAccessExpirationAlert: AccessExpirationAlert };
|
||||
}
|
||||
|
||||
export default useAccessExpirationAlert;
|
||||
1
src/alerts/access-expiration-alert/index.js
Normal file
1
src/alerts/access-expiration-alert/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './hooks';
|
||||
62
src/alerts/enrollment-alert/EnrollmentAlert.jsx
Normal file
62
src/alerts/enrollment-alert/EnrollmentAlert.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
|
||||
import messages from './messages';
|
||||
import { useEnrollClickHandler } from './hooks';
|
||||
|
||||
function EnrollmentAlert({ intl, payload }) {
|
||||
const {
|
||||
canEnroll,
|
||||
courseId,
|
||||
extraText,
|
||||
isStaff,
|
||||
} = payload;
|
||||
|
||||
const { enrollClickHandler, loading } = useEnrollClickHandler(
|
||||
courseId,
|
||||
intl.formatMessage(messages.success),
|
||||
);
|
||||
|
||||
let text = intl.formatMessage(messages.alert);
|
||||
let type = ALERT_TYPES.ERROR;
|
||||
if (isStaff) {
|
||||
text = intl.formatMessage(messages.staffAlert);
|
||||
type = ALERT_TYPES.INFO;
|
||||
} else if (extraText) {
|
||||
text = `${text} ${extraText}`;
|
||||
}
|
||||
|
||||
const button = canEnroll && (
|
||||
<Button disabled={loading} variant="link" className="p-0 border-0 align-top" onClick={enrollClickHandler}>
|
||||
{intl.formatMessage(messages.enroll)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert type={type}>
|
||||
{text}
|
||||
{' '}
|
||||
{button}
|
||||
{' '}
|
||||
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
EnrollmentAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
canEnroll: PropTypes.bool,
|
||||
courseId: PropTypes.string,
|
||||
extraText: PropTypes.string,
|
||||
isStaff: PropTypes.bool,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(EnrollmentAlert);
|
||||
9
src/alerts/enrollment-alert/data/api.js
Normal file
9
src/alerts/enrollment-alert/data/api.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export async function postCourseEnrollment(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
|
||||
const { data } = await getAuthenticatedHttpClient().post(url, { course_details: { course_id: courseId } });
|
||||
return data;
|
||||
}
|
||||
52
src/alerts/enrollment-alert/hooks.js
Normal file
52
src/alerts/enrollment-alert/hooks.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React, {
|
||||
useContext, useState, useCallback, useMemo,
|
||||
} from 'react';
|
||||
|
||||
import { UserMessagesContext, ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
import { postCourseEnrollment } from './data/api';
|
||||
|
||||
const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert'));
|
||||
|
||||
export function useEnrollmentAlert(courseId) {
|
||||
const course = useModel('courses', courseId);
|
||||
const outline = useModel('outline', courseId);
|
||||
const isVisible = course && course.isEnrolled !== undefined && !course.isEnrolled;
|
||||
const payload = {
|
||||
canEnroll: outline.enrollAlert.canEnroll,
|
||||
courseId,
|
||||
extraText: outline.enrollAlert.extraText,
|
||||
isStaff: course.isStaff,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientEnrollmentAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline',
|
||||
});
|
||||
|
||||
return { clientEnrollmentAlert: EnrollmentAlert };
|
||||
}
|
||||
|
||||
export function useEnrollClickHandler(courseId, successText) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { addFlash } = useContext(UserMessagesContext);
|
||||
const enrollClickHandler = useCallback(() => {
|
||||
setLoading(true);
|
||||
postCourseEnrollment(courseId).then(() => {
|
||||
addFlash({
|
||||
dismissible: true,
|
||||
flash: true,
|
||||
text: successText,
|
||||
type: ALERT_TYPES.SUCCESS,
|
||||
topic: 'course',
|
||||
});
|
||||
setLoading(false);
|
||||
global.location.reload();
|
||||
});
|
||||
}, [courseId]);
|
||||
|
||||
return { enrollClickHandler, loading };
|
||||
}
|
||||
1
src/alerts/enrollment-alert/index.js
Normal file
1
src/alerts/enrollment-alert/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { useEnrollmentAlert as default } from './hooks';
|
||||
@@ -1,21 +1,26 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learning.enrollment.alert': {
|
||||
alert: {
|
||||
id: 'learning.enrollment.alert',
|
||||
defaultMessage: 'You must be enrolled in the course to see course content.',
|
||||
description: 'Message shown to indicate that a user needs to enroll in a course prior to viewing the course content. Shown as part of an alert, along with a link to enroll.',
|
||||
},
|
||||
'learning.staff.enrollment.alert': {
|
||||
staffAlert: {
|
||||
id: 'learning.staff.enrollment.alert',
|
||||
defaultMessage: 'You are viewing this course as staff, and are not enrolled.',
|
||||
description: 'Message shown to indicate that a user is not enrolled, but is able to view a course anyway because they are staff. Shown as part of an alert, along with a link to enroll.',
|
||||
},
|
||||
'learning.enrollment.enroll.now': {
|
||||
enroll: {
|
||||
id: 'learning.enrollment.enroll.now',
|
||||
defaultMessage: 'Enroll Now',
|
||||
description: 'A link prompting the user to click on it to enroll in the currently viewed course.',
|
||||
},
|
||||
success: {
|
||||
id: 'learning.enrollment.success',
|
||||
defaultMessage: "You've successfully enrolled in this course!",
|
||||
description: 'A message telling the user that their course enrollment was successful.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -3,13 +3,13 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
|
||||
|
||||
import Alert from '../user-messages/Alert';
|
||||
import { Alert } from '../../generic/user-messages';
|
||||
import messages from './messages';
|
||||
|
||||
function LogistrationAlert({ intl }) {
|
||||
const signIn = (
|
||||
<a href={`${getLoginRedirectUrl(global.location.href)}`}>
|
||||
{intl.formatMessage(messages['learning.logistration.login'])}
|
||||
{intl.formatMessage(messages.login)}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ function LogistrationAlert({ intl }) {
|
||||
// This is complicated by the fact that we don't have a REGISTER_URL env variable available.
|
||||
const register = (
|
||||
<a href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}>
|
||||
{intl.formatMessage(messages['learning.logistration.register'])}
|
||||
{intl.formatMessage(messages.register)}
|
||||
</a>
|
||||
);
|
||||
|
||||
20
src/alerts/logistration-alert/hooks.js
Normal file
20
src/alerts/logistration-alert/hooks.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React, { useContext } from 'react';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
|
||||
const LogistrationAlert = React.lazy(() => import('./LogistrationAlert'));
|
||||
|
||||
export function useLogistrationAlert() {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const isVisible = authenticatedUser === null;
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientLogistrationAlert',
|
||||
topic: 'outline',
|
||||
dismissible: false,
|
||||
type: ALERT_TYPES.ERROR,
|
||||
});
|
||||
|
||||
return { clientLogistrationAlert: LogistrationAlert };
|
||||
}
|
||||
1
src/alerts/logistration-alert/index.js
Normal file
1
src/alerts/logistration-alert/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { useLogistrationAlert as default } from './hooks';
|
||||
@@ -1,12 +1,12 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learning.logistration.login': {
|
||||
login: {
|
||||
id: 'learning.logistration.login',
|
||||
defaultMessage: 'sign in',
|
||||
description: 'Text in a link, prompting the user to log in. Used in "learning.logistration.alert"',
|
||||
},
|
||||
'learning.logistration.register': {
|
||||
register: {
|
||||
id: 'learning.logistration.register',
|
||||
defaultMessage: 'register',
|
||||
description: 'Text in a link, prompting the user to create an account. Used in "learning.logistration.alert"',
|
||||
24
src/alerts/offer-alert/OfferAlert.jsx
Normal file
24
src/alerts/offer-alert/OfferAlert.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||
|
||||
function OfferAlert({ payload }) {
|
||||
const {
|
||||
rawHtml,
|
||||
} = payload;
|
||||
return rawHtml && (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
OfferAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
rawHtml: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default OfferAlert;
|
||||
19
src/alerts/offer-alert/hooks.js
Normal file
19
src/alerts/offer-alert/hooks.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
|
||||
const OfferAlert = React.lazy(() => import('./OfferAlert'));
|
||||
|
||||
export function useOfferAlert(offerHtml, topic) {
|
||||
const rawHtml = offerHtml || null;
|
||||
const isVisible = !!rawHtml; // if it exists, show it.
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientOfferAlert',
|
||||
topic,
|
||||
payload: useMemo(() => ({ rawHtml }), [rawHtml]),
|
||||
});
|
||||
|
||||
return { clientOfferAlert: OfferAlert };
|
||||
}
|
||||
|
||||
export default useOfferAlert;
|
||||
1
src/alerts/offer-alert/index.js
Normal file
1
src/alerts/offer-alert/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './hooks';
|
||||
@@ -1,17 +1,16 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import messages from './messages';
|
||||
import Tabs from '../tabs/Tabs';
|
||||
import Tabs from '../generic/tabs/Tabs';
|
||||
|
||||
function CourseTabsNavigation({
|
||||
activeTabSlug, tabs, intl,
|
||||
activeTabSlug, className, tabs, intl,
|
||||
}) {
|
||||
return (
|
||||
<div className="course-tabs-navigation">
|
||||
<div className={classNames('course-tabs-navigation', className)}>
|
||||
<div className="container-fluid">
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
@@ -21,7 +20,7 @@ function CourseTabsNavigation({
|
||||
<a
|
||||
key={slug}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
|
||||
href={`${getConfig().LMS_BASE_URL}${url}`}
|
||||
href={url}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
@@ -34,9 +33,9 @@ function CourseTabsNavigation({
|
||||
|
||||
CourseTabsNavigation.propTypes = {
|
||||
activeTabSlug: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
tabs: PropTypes.arrayOf(PropTypes.shape({
|
||||
title: PropTypes.string.isRequired,
|
||||
priority: PropTypes.number.isRequired,
|
||||
slug: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
})).isRequired,
|
||||
@@ -45,6 +44,7 @@ CourseTabsNavigation.propTypes = {
|
||||
|
||||
CourseTabsNavigation.defaultProps = {
|
||||
activeTabSlug: undefined,
|
||||
className: null,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseTabsNavigation);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
import { useEnterpriseConfig } from '@edx/frontend-enterprise';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
@@ -33,6 +34,12 @@ export default function Header({
|
||||
}) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
const { enterpriseLearnerPortalLink } = useEnterpriseConfig(
|
||||
authenticatedUser,
|
||||
getConfig().ENTERPRISE_LEARNER_PORTAL_HOSTNAME,
|
||||
getConfig().LMS_BASE_URL,
|
||||
);
|
||||
|
||||
return (
|
||||
<header className="course-header">
|
||||
<div className="container-fluid py-2 d-flex align-items-center ">
|
||||
@@ -48,17 +55,23 @@ export default function Header({
|
||||
</div>
|
||||
|
||||
<Dropdown className="user-dropdown">
|
||||
<Dropdown.Button>
|
||||
<Dropdown.Toggle variant="light">
|
||||
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
|
||||
<span className="d-none d-md-inline">
|
||||
{authenticatedUser.username}
|
||||
</span>
|
||||
</Dropdown.Button>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="dropdown-menu-right">
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>Dashboard</Dropdown.Item>
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${authenticatedUser.username}`}>Profile</Dropdown.Item>
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>Account</Dropdown.Item>
|
||||
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>Order History</Dropdown.Item>
|
||||
{!enterpriseLearnerPortalLink && (
|
||||
// Users should only see Order History if they do not have an available
|
||||
// learner portal, because an available learner portal currently means
|
||||
// that they access content via Subscriptions, in which context an "order"
|
||||
// is not relevant.
|
||||
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>Order History</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item href={getConfig().LOGOUT_URL}>Sign Out</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
@@ -68,7 +81,13 @@ export default function Header({
|
||||
}
|
||||
|
||||
Header.propTypes = {
|
||||
courseOrg: PropTypes.string.isRequired,
|
||||
courseNumber: PropTypes.string.isRequired,
|
||||
courseTitle: PropTypes.string.isRequired,
|
||||
courseOrg: PropTypes.string,
|
||||
courseNumber: PropTypes.string,
|
||||
courseTitle: PropTypes.string,
|
||||
};
|
||||
|
||||
Header.defaultProps = {
|
||||
courseOrg: null,
|
||||
courseNumber: null,
|
||||
courseTitle: null,
|
||||
};
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default function CourseDates({
|
||||
start,
|
||||
end,
|
||||
enrollmentStart,
|
||||
enrollmentEnd,
|
||||
enrollmentMode,
|
||||
isEnrolled,
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<h4>Upcoming Dates</h4>
|
||||
<div><strong>Course Start:</strong><br />{start}</div>
|
||||
<div><strong>Course End:</strong><br />{end}</div>
|
||||
<div><strong>Enrollment Start:</strong><br />{enrollmentStart}</div>
|
||||
<div><strong>Enrollment End:</strong><br />{enrollmentEnd}</div>
|
||||
<div><strong>Mode:</strong><br />{enrollmentMode}</div>
|
||||
<div>{isEnrolled ? 'Active Enrollment' : 'Inactive Enrollment'}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
CourseDates.propTypes = {
|
||||
start: PropTypes.string,
|
||||
end: PropTypes.string,
|
||||
enrollmentStart: PropTypes.string,
|
||||
enrollmentEnd: PropTypes.string,
|
||||
enrollmentMode: PropTypes.string,
|
||||
isEnrolled: PropTypes.bool,
|
||||
};
|
||||
|
||||
CourseDates.defaultProps = {
|
||||
start: null,
|
||||
end: null,
|
||||
enrollmentStart: null,
|
||||
enrollmentEnd: null,
|
||||
enrollmentMode: null,
|
||||
isEnrolled: false,
|
||||
};
|
||||
@@ -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,44 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
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';
|
||||
|
||||
export default function Section({ id, courseId }) {
|
||||
const section = useModel('sections', id);
|
||||
const { title, sequenceIds } = section;
|
||||
return (
|
||||
<Collapsible.Advanced className="collapsible-card mb-2">
|
||||
<Collapsible.Trigger className="collapsible-trigger d-flex align-items-start">
|
||||
<Collapsible.Visible whenClosed>
|
||||
<div style={{ minWidth: '1rem' }}>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</div>
|
||||
</Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen>
|
||||
<div style={{ minWidth: '1rem' }}>
|
||||
<FontAwesomeIcon icon={faChevronDown} />
|
||||
</div>
|
||||
</Collapsible.Visible>
|
||||
<div className="ml-2 flex-grow-1">{title}</div>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Body className="collapsible-body">
|
||||
{sequenceIds.map((sequenceId) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequenceId}
|
||||
courseId={courseId}
|
||||
/>
|
||||
))}
|
||||
</Collapsible.Body>
|
||||
</Collapsible.Advanced>
|
||||
);
|
||||
}
|
||||
|
||||
Section.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -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={`/course/${courseId}/${id}`}>{sequence.title}</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SequenceLink.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
59
src/course-home/data/__factories__/block.factory.js
Normal file
59
src/course-home/data/__factories__/block.factory.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
Factory.define('block')
|
||||
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.option('host', 'http://localhost:18000')
|
||||
// Generating block_id that is similar to md5 hash, but still deterministic
|
||||
.sequence('block_id', id => ('abcd'.repeat(8) + id).slice(-32))
|
||||
.attrs({
|
||||
complete: false,
|
||||
description: null,
|
||||
due: null,
|
||||
graded: false,
|
||||
icon: null,
|
||||
showLink: true,
|
||||
type: 'course',
|
||||
children: [],
|
||||
})
|
||||
.attr('display_name', ['display_name', 'block_id'], (displayName, blockId) => {
|
||||
if (displayName) {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
return blockId;
|
||||
})
|
||||
.attr(
|
||||
'id',
|
||||
['id', 'block_id', 'type', 'courseId'],
|
||||
(id, blockId, type, courseId) => {
|
||||
if (id) {
|
||||
return id;
|
||||
}
|
||||
|
||||
const courseInfo = courseId.split(':')[1];
|
||||
|
||||
return `block-v1:${courseInfo}+type@${type}+block@${blockId}`;
|
||||
},
|
||||
)
|
||||
.attr(
|
||||
'student_view_url',
|
||||
['student_view_url', 'host', 'id'],
|
||||
(url, host, id) => {
|
||||
if (url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return `${host}/xblock/${id}`;
|
||||
},
|
||||
)
|
||||
.attr(
|
||||
'lms_web_url',
|
||||
['lms_web_url', 'host', 'courseId', 'id'],
|
||||
(url, host, courseId, id) => {
|
||||
if (url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return `${host}/courses/${courseId}/jump_to/${id}`;
|
||||
},
|
||||
);
|
||||
86
src/course-home/data/__factories__/courseBlocks.factory.js
Normal file
86
src/course-home/data/__factories__/courseBlocks.factory.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
import './block.factory';
|
||||
|
||||
// Generates an Array of block IDs, either from a single block or an array of blocks.
|
||||
const getIds = (attr) => {
|
||||
const blocks = Array.isArray(attr) ? attr : [attr];
|
||||
return blocks.map(block => block.id);
|
||||
};
|
||||
|
||||
// Generates an Object in { [block.id]: block } format, either from a single block or an array of blocks.
|
||||
const getBlocks = (attr) => {
|
||||
const blocks = Array.isArray(attr) ? attr : [attr];
|
||||
// eslint-disable-next-line no-return-assign,no-sequences
|
||||
return blocks.reduce((acc, block) => (acc[block.id] = block, acc), {});
|
||||
};
|
||||
|
||||
Factory.define('courseBlocks')
|
||||
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.option('units', ['courseId'], courseId => ([
|
||||
Factory.build(
|
||||
'block',
|
||||
{ type: 'vertical' },
|
||||
{ courseId },
|
||||
),
|
||||
]))
|
||||
.option('sequence', ['courseId', 'units'], (courseId, child) => Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential', children: getIds(child) },
|
||||
{ courseId },
|
||||
))
|
||||
.option('section', ['courseId', 'sequence'], (courseId, child) => Factory.build(
|
||||
'block',
|
||||
{ type: 'chapter', children: getIds(child) },
|
||||
{ courseId },
|
||||
))
|
||||
.option('course', ['courseId', 'section'], (courseId, child) => Factory.build(
|
||||
'block',
|
||||
{ type: 'course', children: getIds(child) },
|
||||
{ courseId },
|
||||
))
|
||||
.attr(
|
||||
'blocks',
|
||||
['course', 'section', 'sequence', 'units'],
|
||||
(course, section, sequence, units) => ({
|
||||
[course.id]: course,
|
||||
...getBlocks(section),
|
||||
...getBlocks(sequence),
|
||||
...getBlocks(units),
|
||||
}),
|
||||
)
|
||||
.attr('root', ['course'], course => course.id);
|
||||
|
||||
/**
|
||||
* Builds a course with a single chapter, sequence, and unit.
|
||||
*/
|
||||
export default function buildSimpleCourseBlocks(courseId, title, options = {}) {
|
||||
const sequenceBlock = options.sequenceBlock || [Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential' },
|
||||
{ courseId },
|
||||
)];
|
||||
const sectionBlock = options.sectionBlock || Factory.build(
|
||||
'block',
|
||||
{ type: 'chapter', children: sequenceBlock.map(block => block.id) },
|
||||
{ courseId },
|
||||
);
|
||||
const courseBlock = options.courseBlocks || Factory.build(
|
||||
'block',
|
||||
{ type: 'course', display_name: title, children: [sectionBlock.id] },
|
||||
{ courseId },
|
||||
);
|
||||
return {
|
||||
courseBlocks: options.courseBlocks || Factory.build(
|
||||
'courseBlocks',
|
||||
{ courseId },
|
||||
{
|
||||
sequence: sequenceBlock,
|
||||
section: sectionBlock,
|
||||
course: courseBlock,
|
||||
},
|
||||
),
|
||||
sequenceBlock,
|
||||
sectionBlock,
|
||||
courseBlock,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
Factory.define('courseHomeMetadata')
|
||||
.sequence(
|
||||
'courseId', (courseId) => `course-v1:edX+DemoX+Demo_Course_${courseId}`
|
||||
)
|
||||
.option('host', 'http://localhost:18000')
|
||||
.attrs({
|
||||
is_staff: false,
|
||||
original_user_is_staff: false,
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
title: 'Demonstration Course',
|
||||
is_self_paced: false,
|
||||
})
|
||||
.attr(
|
||||
'tabs', ['courseId', 'host'], (courseId, host) => {
|
||||
const tabs = [
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Course',
|
||||
priority: 0,
|
||||
slug: 'courseware',
|
||||
type: 'courseware',
|
||||
},
|
||||
{ courseId: courseId, path: 'course/' },
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Discussion',
|
||||
priority: 1,
|
||||
slug: 'discussion',
|
||||
type: 'discussion',
|
||||
},
|
||||
{ courseId: courseId, path: 'discussion/forum/' },
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Wiki',
|
||||
priority: 2,
|
||||
slug: 'wiki',
|
||||
type: 'wiki',
|
||||
},
|
||||
{ courseId: courseId, path: 'course_wiki' },
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Progress',
|
||||
priority: 3,
|
||||
slug: 'progress',
|
||||
type: 'progress',
|
||||
},
|
||||
{ courseId: courseId, path: 'progress' },
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Instructor',
|
||||
priority: 4,
|
||||
slug: 'instructor',
|
||||
type: 'instructor',
|
||||
},
|
||||
{ courseId: courseId, path: 'instructor' },
|
||||
),
|
||||
];
|
||||
|
||||
return tabs.map(
|
||||
tab => ({
|
||||
tab_id: tab.slug,
|
||||
title: tab.title,
|
||||
url: `${host}${tab.url}`,
|
||||
})
|
||||
);
|
||||
},
|
||||
);
|
||||
27
src/course-home/data/__factories__/datesTabData.factory.js
Normal file
27
src/course-home/data/__factories__/datesTabData.factory.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
Factory.define('datesTabData')
|
||||
.attrs({
|
||||
dates_banner_info: {
|
||||
content_type_gating_enabled: false,
|
||||
missed_gated_content: false,
|
||||
missed_deadlines: false,
|
||||
},
|
||||
course_date_blocks: [
|
||||
{
|
||||
assigment_type: 'Homework',
|
||||
date: '2013-02-05T05:00:00Z',
|
||||
date_type: 'course-start-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: '',
|
||||
title: 'Course Starts',
|
||||
extraInfo: '',
|
||||
},
|
||||
],
|
||||
missed_deadlines: false,
|
||||
missed_gated_content: false,
|
||||
learner_is_full_access: true,
|
||||
user_timezone: null,
|
||||
verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
});
|
||||
3
src/course-home/data/__factories__/index.js
Normal file
3
src/course-home/data/__factories__/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import './courseHomeMetadata.factory';
|
||||
import './datesTabData.factory';
|
||||
import './outlineTabData.factory';
|
||||
34
src/course-home/data/__factories__/outlineTabData.factory.js
Normal file
34
src/course-home/data/__factories__/outlineTabData.factory.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
import buildSimpleCourseBlocks from './courseBlocks.factory';
|
||||
|
||||
Factory.define('outlineTabData')
|
||||
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.option('host', 'http://localhost:18000')
|
||||
.attr('course_expired_html', [], () => '<div>Course expired</div>')
|
||||
.attr('course_tools', ['host', 'courseId'], (host, courseId) => ({
|
||||
analytics_id: 'edx.bookmarks',
|
||||
title: 'Bookmarks',
|
||||
url: `${host}/courses/${courseId}/bookmarks/`,
|
||||
}))
|
||||
.attr('course_blocks', ['courseId'], courseId => {
|
||||
const { courseBlocks } = buildSimpleCourseBlocks(courseId);
|
||||
return {
|
||||
blocks: courseBlocks.blocks,
|
||||
};
|
||||
})
|
||||
.attr('course_goals', [], () => ({
|
||||
goal_options: [],
|
||||
selected_goal: {},
|
||||
}))
|
||||
.attr('enroll_alert', {
|
||||
can_enroll: true,
|
||||
extra_text: 'Contact the administrator.',
|
||||
})
|
||||
.attr('handouts_html', [], () => '<ul><li>Handout 1</li></ul>')
|
||||
.attr('offer_html', [], () => '<div>Great offer here</div>')
|
||||
.attr('resume_course', ['host', 'courseId'], (host, courseId) => ({
|
||||
has_visited_course: false,
|
||||
url: `${host}/courses/${courseId}/jump_to/block-v1:edX+Test+Block@12345abcde`,
|
||||
}))
|
||||
.attr('welcome_message_html', [], () => '<p>Welcome to this course!</p>');
|
||||
207
src/course-home/data/__snapshots__/redux.test.js.snap
Normal file
207
src/course-home/data/__snapshots__/redux.test.js.snap
Normal file
@@ -0,0 +1,207 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": null,
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"models": Object {
|
||||
"courses": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "courseware",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
||||
},
|
||||
Object {
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
|
||||
},
|
||||
Object {
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
|
||||
},
|
||||
Object {
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
},
|
||||
},
|
||||
"dates": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"courseDateBlocks": Array [
|
||||
Object {
|
||||
"assigmentType": "Homework",
|
||||
"date": "2013-02-05T05:00:00Z",
|
||||
"dateType": "course-start-date",
|
||||
"description": "",
|
||||
"extraInfo": "",
|
||||
"learnerHasAccess": true,
|
||||
"link": "",
|
||||
"title": "Course Starts",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": Object {
|
||||
"contentTypeGatingEnabled": false,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"learnerIsFullAccess": true,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
"userTimezone": null,
|
||||
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": null,
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"models": Object {
|
||||
"courses": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "courseware",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
||||
},
|
||||
Object {
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
|
||||
},
|
||||
Object {
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
|
||||
},
|
||||
Object {
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
},
|
||||
},
|
||||
"outline": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"courseBlocks": Object {
|
||||
"courses": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"sectionIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
],
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
},
|
||||
},
|
||||
"sections": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
|
||||
"complete": false,
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"sequenceIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
],
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
},
|
||||
},
|
||||
"sequences": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
|
||||
"complete": false,
|
||||
"description": null,
|
||||
"due": null,
|
||||
"icon": null,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"showLink": true,
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"courseExpiredHtml": "<div>Course expired</div>",
|
||||
"courseGoals": Object {
|
||||
"goalOptions": Array [],
|
||||
"selectedGoal": Object {},
|
||||
},
|
||||
"courseTools": Object {
|
||||
"analyticsId": "edx.bookmarks",
|
||||
"title": "Bookmarks",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/bookmarks/",
|
||||
},
|
||||
"datesWidget": undefined,
|
||||
"enrollAlert": Object {
|
||||
"canEnroll": true,
|
||||
"extraText": "Contact the administrator.",
|
||||
},
|
||||
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"offerHtml": "<div>Great offer here</div>",
|
||||
"resumeCourse": Object {
|
||||
"hasVisitedCourse": false,
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
|
||||
},
|
||||
"welcomeMessageHtml": "<p>Welcome to this course!</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
`;
|
||||
186
src/course-home/data/api.js
Normal file
186
src/course-home/data/api.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
function normalizeCourseHomeCourseMetadata(metadata) {
|
||||
const data = camelCaseObject(metadata);
|
||||
return {
|
||||
...data,
|
||||
tabs: data.tabs.map(tab => ({
|
||||
slug: tab.tabId,
|
||||
title: tab.title,
|
||||
url: tab.url,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
const models = {
|
||||
courses: {},
|
||||
sections: {},
|
||||
sequences: {},
|
||||
};
|
||||
Object.values(blocks).forEach(block => {
|
||||
switch (block.type) {
|
||||
case 'course':
|
||||
models.courses[block.id] = {
|
||||
id: courseId,
|
||||
title: block.display_name,
|
||||
sectionIds: block.children || [],
|
||||
};
|
||||
break;
|
||||
|
||||
case 'chapter':
|
||||
models.sections[block.id] = {
|
||||
complete: block.complete,
|
||||
id: block.id,
|
||||
title: block.display_name,
|
||||
sequenceIds: block.children || [],
|
||||
};
|
||||
break;
|
||||
|
||||
case 'sequential':
|
||||
models.sequences[block.id] = {
|
||||
complete: block.complete,
|
||||
description: block.description,
|
||||
due: block.due,
|
||||
icon: block.icon,
|
||||
id: block.id,
|
||||
showLink: !!block.lms_web_url, // we reconstruct the url ourselves as an MFE-internal <Link>
|
||||
title: block.display_name,
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
logError(`Unexpected course block type: ${block.type} with ID ${block.id}. Expected block types are course, chapter, and sequential.`);
|
||||
}
|
||||
});
|
||||
|
||||
// Next go through each list and use their child lists to decorate those children with a
|
||||
// reference back to their parent.
|
||||
Object.values(models.courses).forEach(course => {
|
||||
if (Array.isArray(course.sectionIds)) {
|
||||
course.sectionIds.forEach(sectionId => {
|
||||
const section = models.sections[sectionId];
|
||||
section.courseId = course.id;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Object.values(models.sections).forEach(section => {
|
||||
if (Array.isArray(section.sequenceIds)) {
|
||||
section.sequenceIds.forEach(sequenceId => {
|
||||
if (sequenceId in models.sequences) {
|
||||
models.sequences[sequenceId].sectionId = section.id;
|
||||
} else {
|
||||
logError(`Section ${section.id} has child block ${sequenceId}, but that block is not in the list of sequences.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function getCourseHomeCourseMetadata(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return normalizeCourseHomeCourseMetadata(data);
|
||||
}
|
||||
|
||||
export async function getDatesTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgressTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOutlineTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
|
||||
let { tabData } = {};
|
||||
try {
|
||||
tabData = await getAuthenticatedHttpClient().get(url);
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const {
|
||||
data,
|
||||
} = tabData;
|
||||
const courseBlocks = normalizeOutlineBlocks(courseId, data.course_blocks.blocks);
|
||||
const courseGoals = camelCaseObject(data.course_goals);
|
||||
const courseExpiredHtml = data.course_expired_html;
|
||||
const courseTools = camelCaseObject(data.course_tools);
|
||||
const datesWidget = camelCaseObject(data.dates_widget);
|
||||
const enrollAlert = camelCaseObject(data.enroll_alert);
|
||||
const handoutsHtml = data.handouts_html;
|
||||
const offerHtml = data.offer_html;
|
||||
const resumeCourse = camelCaseObject(data.resume_course);
|
||||
const welcomeMessageHtml = data.welcome_message_html;
|
||||
|
||||
return {
|
||||
courseBlocks,
|
||||
courseGoals,
|
||||
courseExpiredHtml,
|
||||
courseTools,
|
||||
datesWidget,
|
||||
enrollAlert,
|
||||
handoutsHtml,
|
||||
offerHtml,
|
||||
resumeCourse,
|
||||
welcomeMessageHtml,
|
||||
};
|
||||
}
|
||||
|
||||
export async function postCourseDeadlines(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`);
|
||||
return getAuthenticatedHttpClient().post(url.href, { course_key: courseId });
|
||||
}
|
||||
|
||||
export async function postCourseGoals(courseId, goalKey) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`);
|
||||
return getAuthenticatedHttpClient().post(url.href, { course_id: courseId, goal_key: goalKey });
|
||||
}
|
||||
|
||||
export async function postDismissWelcomeMessage(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`);
|
||||
await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
|
||||
}
|
||||
|
||||
export async function postRequestCert(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/generate_user_cert`);
|
||||
await getAuthenticatedHttpClient().post(url.href);
|
||||
}
|
||||
|
||||
export async function executePostFromPostEvent(postData) {
|
||||
const url = new URL(postData.url);
|
||||
return getAuthenticatedHttpClient().post(url.href, { course_key: postData.bodyParams.courseId });
|
||||
}
|
||||
9
src/course-home/data/index.js
Normal file
9
src/course-home/data/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
fetchDatesTab,
|
||||
fetchOutlineTab,
|
||||
fetchProgressTab,
|
||||
resetDeadlines,
|
||||
saveCourseGoal,
|
||||
} from './thunks';
|
||||
|
||||
export { reducer } from './slice';
|
||||
131
src/course-home/data/redux.test.js
Normal file
131
src/course-home/data/redux.test.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Factory } from 'rosie';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import * as thunks from './thunks';
|
||||
|
||||
import executeThunk from '../../utils';
|
||||
|
||||
import initializeMockApp from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
|
||||
const { loggingService } = initializeMockApp();
|
||||
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
describe('Data layer integration tests', () => {
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata');
|
||||
const courseId = courseHomeMetadata.courseId;
|
||||
const courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock.reset();
|
||||
loggingService.logError.mockReset();
|
||||
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
describe('Test fetchDatesTab', () => {
|
||||
const datesBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates`;
|
||||
|
||||
it('Should fail to fetch if error occurs', async () => {
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).networkError();
|
||||
|
||||
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
it('Should fetch, normalize, and save metadata', async () => {
|
||||
const datesTabData = Factory.build('datesTabData');
|
||||
|
||||
const datesUrl = `${datesBaseUrl}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
|
||||
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test fetchOutlineTab', () => {
|
||||
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline`;
|
||||
|
||||
it('Should result in fetch failure if error occurs', async () => {
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
|
||||
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
it('Should fetch, normalize, and save metadata', async () => {
|
||||
const outlineTabData = Factory.build('outlineTabData', { courseId });
|
||||
|
||||
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
||||
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test saveCourseGoal', () => {
|
||||
it('Should save course goal', async () => {
|
||||
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`;
|
||||
axiosMock.onPost(goalUrl).reply(200, {});
|
||||
|
||||
await thunks.saveCourseGoal(courseId, 'unsure');
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(goalUrl);
|
||||
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}","goal_key":"unsure"}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test resetDeadlines', () => {
|
||||
it('Should reset course deadlines', async () => {
|
||||
const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
|
||||
axiosMock.onPost(resetUrl).reply(201, {});
|
||||
|
||||
const getTabDataMock = jest.fn(() => ({
|
||||
type: 'MOCK_ACTION',
|
||||
}));
|
||||
|
||||
await executeThunk(thunks.resetDeadlines(courseId, getTabDataMock), store.dispatch);
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(resetUrl);
|
||||
expect(axiosMock.history.post[0].data).toEqual(`{"course_key":"${courseId}"}`);
|
||||
|
||||
expect(getTabDataMock).toHaveBeenCalledWith(courseId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test dismissWelcomeMessage', () => {
|
||||
it('Should dismiss welcome message', async () => {
|
||||
const dismissUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`;
|
||||
axiosMock.onPost(dismissUrl).reply(201);
|
||||
|
||||
await executeThunk(thunks.dismissWelcomeMessage(courseId), store.dispatch);
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(dismissUrl);
|
||||
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}"}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
52
src/course-home/data/slice.js
Normal file
52
src/course-home/data/slice.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'course-home',
|
||||
initialState: {
|
||||
courseStatus: 'loading',
|
||||
courseId: null,
|
||||
toastBodyText: null,
|
||||
toastBodyLink: null,
|
||||
toastHeader: null,
|
||||
},
|
||||
reducers: {
|
||||
fetchTabRequest: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = LOADING;
|
||||
},
|
||||
fetchTabSuccess: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = LOADED;
|
||||
},
|
||||
fetchTabFailure: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = FAILED;
|
||||
},
|
||||
setCallToActionToast: (state, { payload }) => {
|
||||
const {
|
||||
header,
|
||||
link,
|
||||
linkText,
|
||||
} = payload;
|
||||
state.toastBodyLink = link;
|
||||
state.toastBodyText = linkText;
|
||||
state.toastHeader = header;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
fetchTabFailure,
|
||||
setCallToActionToast,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
128
src/course-home/data/thunks.js
Normal file
128
src/course-home/data/thunks.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import {
|
||||
executePostFromPostEvent,
|
||||
getCourseHomeCourseMetadata,
|
||||
getDatesTabData,
|
||||
getOutlineTabData,
|
||||
getProgressTabData,
|
||||
postCourseDeadlines,
|
||||
postCourseGoals,
|
||||
postDismissWelcomeMessage,
|
||||
postRequestCert,
|
||||
} from './api';
|
||||
|
||||
import {
|
||||
addModel,
|
||||
} from '../../generic/model-store';
|
||||
|
||||
import {
|
||||
fetchTabFailure,
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
setCallToActionToast,
|
||||
} from './slice';
|
||||
|
||||
const eventTypes = {
|
||||
POST_EVENT: 'post_event',
|
||||
};
|
||||
|
||||
export function fetchTab(courseId, tab, getTabData) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchTabRequest({ courseId }));
|
||||
Promise.allSettled([
|
||||
getCourseHomeCourseMetadata(courseId),
|
||||
getTabData(courseId),
|
||||
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
|
||||
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
|
||||
const fetchedTabData = tabDataResult.status === 'fulfilled';
|
||||
|
||||
if (fetchedCourseHomeCourseMetadata) {
|
||||
dispatch(addModel({
|
||||
modelType: 'courses',
|
||||
model: {
|
||||
id: courseId,
|
||||
...courseHomeCourseMetadataResult.value,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
logError(courseHomeCourseMetadataResult.reason);
|
||||
}
|
||||
|
||||
if (fetchedTabData) {
|
||||
dispatch(addModel({
|
||||
modelType: tab,
|
||||
model: {
|
||||
id: courseId,
|
||||
...tabDataResult.value,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
logError(tabDataResult.reason);
|
||||
}
|
||||
|
||||
if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
|
||||
dispatch(fetchTabSuccess({ courseId }));
|
||||
} else {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchDatesTab(courseId) {
|
||||
return fetchTab(courseId, 'dates', getDatesTabData);
|
||||
}
|
||||
|
||||
export function fetchProgressTab(courseId) {
|
||||
return fetchTab(courseId, 'progress', getProgressTabData);
|
||||
}
|
||||
|
||||
export function fetchOutlineTab(courseId) {
|
||||
return fetchTab(courseId, 'outline', getOutlineTabData);
|
||||
}
|
||||
|
||||
export function dismissWelcomeMessage(courseId) {
|
||||
return async () => postDismissWelcomeMessage(courseId);
|
||||
}
|
||||
|
||||
export function requestCert(courseId) {
|
||||
return async () => postRequestCert(courseId);
|
||||
}
|
||||
|
||||
export function resetDeadlines(courseId, getTabData) {
|
||||
return async (dispatch) => {
|
||||
postCourseDeadlines(courseId).then(response => {
|
||||
const { data } = response;
|
||||
const {
|
||||
header,
|
||||
link,
|
||||
link_text: linkText,
|
||||
} = data;
|
||||
dispatch(getTabData(courseId));
|
||||
dispatch(setCallToActionToast({ header, link, linkText }));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveCourseGoal(courseId, goalKey) {
|
||||
return postCourseGoals(courseId, goalKey);
|
||||
}
|
||||
|
||||
export function processEvent(eventData, getTabData) {
|
||||
return async (dispatch) => {
|
||||
const event = camelCaseObject(eventData);
|
||||
if (event.eventName === eventTypes.POST_EVENT) {
|
||||
executePostFromPostEvent(event.postData).then(response => {
|
||||
const { data } = response;
|
||||
const {
|
||||
header,
|
||||
link,
|
||||
link_text: linkText,
|
||||
} = data;
|
||||
dispatch(getTabData(event.postData.bodyParams.courseId));
|
||||
dispatch(setCallToActionToast({ header, link, linkText }));
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
45
src/course-home/dates-banner/DatesBanner.jsx
Normal file
45
src/course-home/dates-banner/DatesBanner.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function DatesBanner(props) {
|
||||
const {
|
||||
intl,
|
||||
name,
|
||||
bannerClickHandler,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="banner rounded my-4 p-4 container-fluid border border-primary-200 bg-info-100">
|
||||
<div className="row w-100 m-0 justify-content-start justify-content-sm-between">
|
||||
<div className={name === 'datesTabInfoBanner' ? 'col-12' : 'col-12 col-md-9'}>
|
||||
<strong>
|
||||
{intl.formatMessage(messages[`datesBanner.${name}.header`])}
|
||||
</strong>
|
||||
{intl.formatMessage(messages[`datesBanner.${name}.body`])}
|
||||
</div>
|
||||
{bannerClickHandler && (
|
||||
<div className="col-auto col-md-3 p-md-0 d-inline-flex align-items-center justify-content-start justify-content-md-center">
|
||||
<button type="button" className="btn rounded align-self-center border border-primary bg-white mt-3 mt-md-0 font-weight-bold" onClick={bannerClickHandler}>
|
||||
{intl.formatMessage(messages[`datesBanner.${name}.button`])}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DatesBanner.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
bannerClickHandler: PropTypes.func,
|
||||
};
|
||||
|
||||
DatesBanner.defaultProps = {
|
||||
bannerClickHandler: null,
|
||||
};
|
||||
|
||||
export default injectIntl(DatesBanner);
|
||||
82
src/course-home/dates-banner/DatesBannerContainer.jsx
Normal file
82
src/course-home/dates-banner/DatesBannerContainer.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
import DatesBanner from './DatesBanner';
|
||||
import { fetchDatesTab, resetDeadlines } from '../data/thunks';
|
||||
|
||||
function DatesBannerContainer(props) {
|
||||
const {
|
||||
model,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
courseDateBlocks,
|
||||
datesBannerInfo,
|
||||
hasEnded,
|
||||
} = useModel(model, courseId);
|
||||
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
missedDeadlines,
|
||||
missedGatedContent,
|
||||
verifiedUpgradeLink,
|
||||
} = datesBannerInfo;
|
||||
|
||||
const {
|
||||
isSelfPaced,
|
||||
} = useModel('courses', courseId);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const hasDeadlines = courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
|
||||
const upgradeToCompleteGraded = model === 'dates' && contentTypeGatingEnabled && !missedDeadlines;
|
||||
const upgradeToReset = !upgradeToCompleteGraded && missedDeadlines && missedGatedContent;
|
||||
const resetDates = !upgradeToCompleteGraded && missedDeadlines && !missedGatedContent;
|
||||
const datesBanners = [
|
||||
{
|
||||
name: 'datesTabInfoBanner',
|
||||
shouldDisplay: model === 'dates' && hasDeadlines && !missedDeadlines && isSelfPaced,
|
||||
},
|
||||
{
|
||||
name: 'upgradeToCompleteGradedBanner',
|
||||
// verifiedUpgradeLink can be null if we've passed the upgrade deadline
|
||||
shouldDisplay: upgradeToCompleteGraded && verifiedUpgradeLink,
|
||||
clickHandler: () => global.location.replace(verifiedUpgradeLink),
|
||||
},
|
||||
{
|
||||
name: 'upgradeToResetBanner',
|
||||
// verifiedUpgradeLink can be null if we've passed the upgrade deadline
|
||||
shouldDisplay: upgradeToReset && verifiedUpgradeLink,
|
||||
clickHandler: () => global.location.replace(verifiedUpgradeLink),
|
||||
},
|
||||
{
|
||||
name: 'resetDatesBanner',
|
||||
shouldDisplay: resetDates,
|
||||
clickHandler: () => dispatch(resetDeadlines(courseId, fetchDatesTab)),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hasEnded && datesBanners.map((banner) => banner.shouldDisplay && (
|
||||
<DatesBanner
|
||||
name={banner.name}
|
||||
bannerClickHandler={banner.clickHandler}
|
||||
key={banner.name}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
DatesBannerContainer.propTypes = {
|
||||
model: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default DatesBannerContainer;
|
||||
3
src/course-home/dates-banner/index.js
Normal file
3
src/course-home/dates-banner/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import DatesBannerContainer from './DatesBannerContainer';
|
||||
|
||||
export default DatesBannerContainer;
|
||||
66
src/course-home/dates-banner/messages.js
Normal file
66
src/course-home/dates-banner/messages.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'datesBanner.datesTabInfoBanner.header': {
|
||||
id: 'datesBanner.datesTabInfoBanner.header',
|
||||
defaultMessage: "We've built a suggested schedule to help you stay on track. ",
|
||||
description: 'Strong text in Dates Tab Info Banner',
|
||||
},
|
||||
'datesBanner.datesTabInfoBanner.body': {
|
||||
id: 'datesBanner.datesTabInfoBanner.body',
|
||||
defaultMessage: `But don't worry—it's flexible so you can learn at your own pace. If you happen to fall behind on
|
||||
our suggested dates, you'll be able to adjust them to keep yourself on track.`,
|
||||
description: 'Body in Dates Tab Info Banner',
|
||||
},
|
||||
'datesBanner.upgradeToCompleteGradedBanner.header': {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.header',
|
||||
defaultMessage: 'You are auditing this course, ',
|
||||
description: 'Strong text in Upgrade To Complete Graded Banner',
|
||||
},
|
||||
'datesBanner.upgradeToCompleteGradedBanner.body': {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.body',
|
||||
defaultMessage: `which means that you are unable to participate in graded assignments. To complete graded
|
||||
assignments as part of this course, you can upgrade today.`,
|
||||
description: 'Body in Upgrade To Complete Graded Banner',
|
||||
},
|
||||
'datesBanner.upgradeToCompleteGradedBanner.button': {
|
||||
id: 'datesBanner.upgradeToCompleteGradedBanner.button',
|
||||
defaultMessage: 'Upgrade now',
|
||||
description: 'Button in Upgrade To Complete Graded Banner',
|
||||
},
|
||||
'datesBanner.upgradeToResetBanner.header': {
|
||||
id: 'datesBanner.upgradeToResetBanner.header',
|
||||
defaultMessage: 'You are auditing this course, ',
|
||||
description: 'Strong text in Upgrade To Reset Banner',
|
||||
},
|
||||
'datesBanner.upgradeToResetBanner.body': {
|
||||
id: 'datesBanner.upgradeToResetBanner.body',
|
||||
defaultMessage: `which means that you are unable to participate in graded assignments. It looks like you missed
|
||||
some important deadlines based on our suggested schedule. To complete graded assignments as part of this course
|
||||
and shift the past due assignments into the future, you can upgrade today.`,
|
||||
description: 'Body in Upgrade To Reset Banner',
|
||||
},
|
||||
'datesBanner.upgradeToResetBanner.button': {
|
||||
id: 'datesBanner.upgradeToResetBanner.button',
|
||||
defaultMessage: 'Upgrade to shift due dates',
|
||||
description: 'Button in Upgrade To Reset Banner',
|
||||
},
|
||||
'datesBanner.resetDatesBanner.header': {
|
||||
id: 'datesBanner.resetDatesBanner.header',
|
||||
defaultMessage: 'It looks like you missed some important deadlines based on our suggested schedule. ',
|
||||
description: 'Strong text in Reset Dates Banner',
|
||||
},
|
||||
'datesBanner.resetDatesBanner.body': {
|
||||
id: 'datesBanner.resetDatesBanner.body',
|
||||
defaultMessage: `To keep yourself on track, you can update this schedule and shift the past due assignments into
|
||||
the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.`,
|
||||
description: 'Body in Reset Dates Banner',
|
||||
},
|
||||
'datesBanner.resetDatesBanner.button': {
|
||||
id: 'datesBanner.resetDatesBanner.button',
|
||||
defaultMessage: 'Shift due dates',
|
||||
description: 'Button in Reset Dates Banner',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
21
src/course-home/dates-tab/Badge.jsx
Normal file
21
src/course-home/dates-tab/Badge.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default function Badge({ children, className }) {
|
||||
return (
|
||||
<span className={classNames('dates-badge badge align-text-bottom font-italic ml-2 px-2 py-1', className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Badge.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
Badge.defaultProps = {
|
||||
children: null,
|
||||
className: null,
|
||||
};
|
||||
3
src/course-home/dates-tab/Badge.scss
Normal file
3
src/course-home/dates-tab/Badge.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.dates-badge {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
24
src/course-home/dates-tab/DatesTab.jsx
Normal file
24
src/course-home/dates-tab/DatesTab.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import Timeline from './Timeline';
|
||||
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
|
||||
|
||||
function DatesTab({ intl }) {
|
||||
return (
|
||||
<>
|
||||
<div role="heading" aria-level="1" className="h4 my-3">
|
||||
{intl.formatMessage(messages.title)}
|
||||
</div>
|
||||
<DatesBannerContainer model="dates" />
|
||||
<Timeline />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
DatesTab.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(DatesTab);
|
||||
109
src/course-home/dates-tab/Day.jsx
Normal file
109
src/course-home/dates-tab/Day.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { FormattedDate, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Tooltip, OverlayTrigger } from '@edx/paragon';
|
||||
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
import { getBadgeListAndColor } from './badgelist';
|
||||
import { isLearnerAssignment } from './utils';
|
||||
|
||||
function Day({
|
||||
date, first, intl, items, last,
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
userTimezone,
|
||||
} = useModel('dates', courseId);
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const { color, badges } = getBadgeListAndColor(date, intl, null, items);
|
||||
|
||||
return (
|
||||
<li className="dates-day pb-4">
|
||||
{/* Top Line */}
|
||||
{!first && <div className="dates-line-top border-1 border-left border-gray-900 bg-gray-900" />}
|
||||
|
||||
{/* Dot */}
|
||||
<div className={classNames(color, 'dates-dot border border-gray-900')} />
|
||||
|
||||
{/* Bottom Line */}
|
||||
{!last && <div className="dates-line-bottom border-1 border-left border-gray-900 bg-gray-900" />}
|
||||
|
||||
{/* Content */}
|
||||
<div className="d-inline-block ml-3 pl-2">
|
||||
<div className="mb-1">
|
||||
<p className="d-inline text-dark-500 font-weight-bold">
|
||||
<FormattedDate
|
||||
value={date}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
year="numeric"
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</p>
|
||||
{badges}
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
const { badges: itemBadges } = getBadgeListAndColor(date, intl, item, items);
|
||||
const showLink = item.link && isLearnerAssignment(item);
|
||||
const title = showLink ? (<u><a href={item.link} className="text-reset">{item.title}</a></u>) : item.title;
|
||||
const available = item.learnerHasAccess && (item.link || !isLearnerAssignment(item));
|
||||
const textColor = available ? 'text-dark-500' : 'text-dark-200';
|
||||
return (
|
||||
<div key={item.title + item.date} className={textColor}>
|
||||
<div>
|
||||
<span className="font-weight-bold small mt-1">
|
||||
{item.assignmentType && `${item.assignmentType}: `}{title}
|
||||
</span>
|
||||
{itemBadges}
|
||||
{item.extraInfo && (
|
||||
<OverlayTrigger
|
||||
placement="bottom"
|
||||
overlay={
|
||||
<Tooltip>{item.extraInfo}</Tooltip>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="fa-xs ml-1 text-gray-700" />
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</div>
|
||||
{item.description && <div className="small mb-2">{item.description}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
Day.propTypes = {
|
||||
date: PropTypes.objectOf(Date).isRequired,
|
||||
first: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
date: PropTypes.string,
|
||||
dateType: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
dueNext: PropTypes.bool,
|
||||
learnerHasAccess: PropTypes.bool,
|
||||
link: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
})).isRequired,
|
||||
last: PropTypes.bool,
|
||||
};
|
||||
|
||||
Day.defaultProps = {
|
||||
first: false,
|
||||
last: false,
|
||||
};
|
||||
|
||||
export default injectIntl(Day);
|
||||
47
src/course-home/dates-tab/Day.scss
Normal file
47
src/course-home/dates-tab/Day.scss
Normal file
@@ -0,0 +1,47 @@
|
||||
$dot-radius: 0.3rem;
|
||||
$dot-size: $dot-radius * 2;
|
||||
$offset: $dot-radius * 1.5;
|
||||
|
||||
.dates-day {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dates-line-top {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
left: $offset;
|
||||
top: 0;
|
||||
height: $offset;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.dates-dot {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
left: $dot-radius * 0.5; // save room for today's larger size
|
||||
top: $offset;
|
||||
height: $dot-size;
|
||||
width: $dot-size;
|
||||
z-index: 1;
|
||||
|
||||
&.dates-bg-today {
|
||||
left: 0;
|
||||
top: $offset - $dot-radius;
|
||||
height: $dot-size * 1.5;
|
||||
width: $dot-size * 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.dates-line-bottom {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: $offset + $dot-size;
|
||||
bottom: 0;
|
||||
left: $offset;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.dates-bg-today {
|
||||
background: #ffdb87;
|
||||
}
|
||||
70
src/course-home/dates-tab/Timeline.jsx
Normal file
70
src/course-home/dates-tab/Timeline.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
import Day from './Day';
|
||||
import { daycmp, isLearnerAssignment } from './utils';
|
||||
|
||||
export default function Timeline() {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
courseDateBlocks,
|
||||
} = useModel('dates', courseId);
|
||||
|
||||
// Group date items by day (assuming they are sorted in first place) and add some metadata
|
||||
const groupedDates = [];
|
||||
const now = new Date();
|
||||
let foundNextDue = false;
|
||||
let foundToday = false;
|
||||
courseDateBlocks.forEach(courseDateBlock => {
|
||||
const dateInfo = { ...courseDateBlock };
|
||||
const parsedDate = new Date(dateInfo.date);
|
||||
|
||||
if (!foundNextDue && parsedDate >= now && isLearnerAssignment(dateInfo) && !dateInfo.complete) {
|
||||
foundNextDue = true;
|
||||
dateInfo.dueNext = true;
|
||||
}
|
||||
|
||||
if (!foundToday) {
|
||||
const compared = daycmp(parsedDate, now);
|
||||
if (compared === 0) {
|
||||
foundToday = true;
|
||||
} else if (compared > 0) {
|
||||
foundToday = true;
|
||||
groupedDates.push({
|
||||
date: now,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (groupedDates.length === 0 || daycmp(groupedDates[groupedDates.length - 1].date, parsedDate) !== 0) {
|
||||
// Add new grouped date
|
||||
groupedDates.push({
|
||||
date: parsedDate,
|
||||
items: [dateInfo],
|
||||
first: groupedDates.length === 0,
|
||||
});
|
||||
} else {
|
||||
groupedDates[groupedDates.length - 1].items.push(dateInfo);
|
||||
}
|
||||
});
|
||||
if (!foundToday) {
|
||||
groupedDates.push({ date: now, items: [] });
|
||||
}
|
||||
if (groupedDates.length) {
|
||||
groupedDates[groupedDates.length - 1].last = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="list-unstyled m-0">
|
||||
{groupedDates.map((groupedDate) => (
|
||||
<Day key={groupedDate.date} {...groupedDate} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
115
src/course-home/dates-tab/badgelist.jsx
Normal file
115
src/course-home/dates-tab/badgelist.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import Badge from './Badge';
|
||||
import messages from './messages';
|
||||
import { daycmp, isLearnerAssignment } from './utils';
|
||||
|
||||
function hasAccess(item) {
|
||||
return item.learnerHasAccess;
|
||||
}
|
||||
|
||||
function isComplete(assignment) {
|
||||
return assignment.complete;
|
||||
}
|
||||
|
||||
function isPastDue(assignment) {
|
||||
return !isComplete(assignment) && (new Date(assignment.date) < new Date());
|
||||
}
|
||||
|
||||
function isUnreleased(assignment) {
|
||||
return !assignment.link;
|
||||
}
|
||||
|
||||
// Pass a null item if you want to get a whole day's badge list, not just one item's list.
|
||||
// Returns an object with 'color' and 'badges' properties.
|
||||
function getBadgeListAndColor(date, intl, item, items) {
|
||||
const now = new Date();
|
||||
const assignments = items.filter(isLearnerAssignment);
|
||||
const isToday = daycmp(date, now) === 0;
|
||||
const isInFuture = daycmp(date, now) > 0;
|
||||
|
||||
// This badge info list is in order of priority (they will appear left to right in this order and the first badge
|
||||
// sets the color of the dot in the timeline).
|
||||
const badgesInfo = [
|
||||
{
|
||||
message: messages.today,
|
||||
shownForDay: isToday,
|
||||
bg: 'dates-bg-today',
|
||||
},
|
||||
{
|
||||
message: messages.completed,
|
||||
shownForDay: assignments.length && assignments.every(isComplete),
|
||||
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
|
||||
bg: 'bg-dark-100',
|
||||
},
|
||||
{
|
||||
message: messages.pastDue,
|
||||
shownForDay: assignments.length && assignments.every(isPastDue),
|
||||
shownForItem: x => isLearnerAssignment(x) && isPastDue(x),
|
||||
bg: 'bg-dark-200',
|
||||
},
|
||||
{
|
||||
message: messages.dueNext,
|
||||
shownForDay: !isToday && assignments.some(x => x.dueNext),
|
||||
shownForItem: x => x.dueNext,
|
||||
bg: 'bg-gray-500',
|
||||
className: 'text-white',
|
||||
},
|
||||
{
|
||||
message: messages.unreleased,
|
||||
shownForDay: assignments.length && assignments.every(isUnreleased),
|
||||
shownForItem: x => isLearnerAssignment(x) && isUnreleased(x),
|
||||
className: 'border border-dark-200 text-gray-500 align-top',
|
||||
},
|
||||
{
|
||||
message: messages.verifiedOnly,
|
||||
shownForDay: items.length && items.every(x => !hasAccess(x)),
|
||||
shownForItem: x => !hasAccess(x),
|
||||
icon: faLock,
|
||||
bg: 'bg-dark-500',
|
||||
className: 'text-white',
|
||||
},
|
||||
];
|
||||
let color = null; // first color of any badge
|
||||
const badges = (
|
||||
<>
|
||||
{badgesInfo.map(b => {
|
||||
let shown = b.shownForDay;
|
||||
if (item) {
|
||||
if (b.shownForDay) {
|
||||
shown = false; // don't double up, if the day already has this badge
|
||||
} else {
|
||||
shown = b.shownForItem && b.shownForItem(item);
|
||||
}
|
||||
}
|
||||
if (!shown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!color && !isInFuture) {
|
||||
color = b.bg;
|
||||
}
|
||||
return (
|
||||
<Badge key={b.message.id} className={classNames(b.bg, b.className)}>
|
||||
{b.icon && <FontAwesomeIcon icon={b.icon} className="mr-1" />}
|
||||
{intl.formatMessage(b.message)}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
if (!color && isInFuture) {
|
||||
color = 'bg-gray-900';
|
||||
}
|
||||
|
||||
return {
|
||||
color,
|
||||
badges,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { getBadgeListAndColor };
|
||||
228
src/course-home/dates-tab/fakeData.js
Normal file
228
src/course-home/dates-tab/fakeData.js
Normal file
@@ -0,0 +1,228 @@
|
||||
// Sample data helpful when developing, to see a variety of configurations.
|
||||
// This set of data is not realistic (mix of having access and not), but it
|
||||
// is intended to demonstrate many UI results.
|
||||
// To use, have getDatesTabData in api.js return the result of this call instead:
|
||||
/*
|
||||
import fakeDatesData from '../dates-tab/fakeData';
|
||||
export async function getDatesTabData(courseId, version) {
|
||||
if (tab === 'dates') { return camelCaseObject(fakeDatesData()); }
|
||||
...
|
||||
}
|
||||
*/
|
||||
|
||||
export default function fakeDatesData() {
|
||||
return JSON.parse(`
|
||||
{
|
||||
"course_date_blocks": [
|
||||
{
|
||||
"date": "2020-05-01T17:59:41Z",
|
||||
"date_type": "course-start-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "",
|
||||
"title": "Course Starts",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-04T02:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"title": "Multi Badges Completed",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2020-05-05T02:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"title": "Multi Badges Past Due",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"date": "2020-06-16T17:59:40.942669Z",
|
||||
"date_type": "verified-upgrade-deadline",
|
||||
"description": "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Upgrade to Verified Certificate",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "ORA Verified 2",
|
||||
"extra_info": "ORA Dates are set by the instructor, and can't be changed"
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"title": "One Unreleased 1"
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Unreleased 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"title": "Both Unreleased 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"title": "Both Unreleased 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"date": "2030-08-23T00:00:00Z",
|
||||
"date_type": "course-end-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "",
|
||||
"title": "Course Ends",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"date": "2030-09-01T00:00:00Z",
|
||||
"date_type": "verification-deadline-date",
|
||||
"description": "You must successfully complete verification before this date to qualify for a Verified Certificate.",
|
||||
"learner_has_access": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Verification Deadline",
|
||||
"extra_info": null
|
||||
}
|
||||
],
|
||||
"display_reset_dates_text": false,
|
||||
"learner_is_verified": false,
|
||||
"user_timezone": "America/New_York",
|
||||
"verified_upgrade_link": "https://example.com/"
|
||||
}
|
||||
`);
|
||||
}
|
||||
3
src/course-home/dates-tab/index.jsx
Normal file
3
src/course-home/dates-tab/index.jsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import DatesTab from './DatesTab';
|
||||
|
||||
export default DatesTab;
|
||||
34
src/course-home/dates-tab/messages.js
Normal file
34
src/course-home/dates-tab/messages.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
completed: {
|
||||
id: 'learning.dates.badge.completed',
|
||||
defaultMessage: 'Completed',
|
||||
},
|
||||
dueNext: {
|
||||
id: 'learning.dates.badge.dueNext',
|
||||
defaultMessage: 'Due Next',
|
||||
},
|
||||
pastDue: {
|
||||
id: 'learning.dates.badge.pastDue',
|
||||
defaultMessage: 'Past Due',
|
||||
},
|
||||
title: {
|
||||
id: 'learning.dates.title',
|
||||
defaultMessage: 'Important Dates',
|
||||
},
|
||||
today: {
|
||||
id: 'learning.dates.badge.today',
|
||||
defaultMessage: 'Today',
|
||||
},
|
||||
unreleased: {
|
||||
id: 'learning.dates.badge.unreleased',
|
||||
defaultMessage: 'Not Yet Released',
|
||||
},
|
||||
verifiedOnly: {
|
||||
id: 'learning.dates.badge.verifiedOnly',
|
||||
defaultMessage: 'Verified Only',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
16
src/course-home/dates-tab/utils.jsx
Normal file
16
src/course-home/dates-tab/utils.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
function daycmp(a, b) {
|
||||
if (a.getFullYear() < b.getFullYear()) { return -1; }
|
||||
if (a.getFullYear() > b.getFullYear()) { return 1; }
|
||||
if (a.getMonth() < b.getMonth()) { return -1; }
|
||||
if (a.getMonth() > b.getMonth()) { return 1; }
|
||||
if (a.getDate() < b.getDate()) { return -1; }
|
||||
if (a.getDate() > b.getDate()) { return 1; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
// item is a date block returned from the API
|
||||
function isLearnerAssignment(item) {
|
||||
return item.learnerHasAccess && item.dateType === 'assignment-due-date';
|
||||
}
|
||||
|
||||
export { daycmp, isLearnerAssignment };
|
||||
@@ -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;
|
||||
62
src/course-home/outline-tab/DateSummary.jsx
Normal file
62
src/course-home/outline-tab/DateSummary.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
import { FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isLearnerAssignment } from '../dates-tab/utils';
|
||||
import './DateSummary.scss';
|
||||
|
||||
export default function DateSummary({
|
||||
dateBlock,
|
||||
userTimezone,
|
||||
}) {
|
||||
const linkedTitle = dateBlock.link && isLearnerAssignment(dateBlock);
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
return (
|
||||
<section className="container p-0 mb-3">
|
||||
<div className="row">
|
||||
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" style={{ width: '20px' }} />
|
||||
<div className="ml-2 font-weight-bold">
|
||||
<FormattedDate
|
||||
value={dateBlock.date}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
year="numeric"
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row ml-4 px-2">
|
||||
<div className="date-summary-text">
|
||||
{linkedTitle
|
||||
&& <div className="font-weight-bold mt-2"><a href={dateBlock.link}>{dateBlock.title}</a></div>}
|
||||
{!linkedTitle
|
||||
&& <div className="font-weight-bold mt-2">{dateBlock.title}</div>}
|
||||
</div>
|
||||
{dateBlock.description
|
||||
&& <div className="date-summary-text m-0 mt-1">{dateBlock.description}</div>}
|
||||
{!linkedTitle && dateBlock.link
|
||||
&& <a href={dateBlock.link} className="description-link">{dateBlock.linkText}</a>}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
DateSummary.propTypes = {
|
||||
dateBlock: PropTypes.shape({
|
||||
date: PropTypes.string.isRequired,
|
||||
dateType: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
link: PropTypes.string,
|
||||
linkText: PropTypes.string,
|
||||
title: PropTypes.string.isRequired,
|
||||
learnerHasAccess: PropTypes.bool,
|
||||
}).isRequired,
|
||||
userTimezone: PropTypes.string,
|
||||
};
|
||||
|
||||
DateSummary.defaultProps = {
|
||||
userTimezone: null,
|
||||
};
|
||||
8
src/course-home/outline-tab/DateSummary.scss
Normal file
8
src/course-home/outline-tab/DateSummary.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
.date-summary-text {
|
||||
margin-left: 2px;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.description-link {
|
||||
margin-left: 1px;
|
||||
}
|
||||
39
src/course-home/outline-tab/LmsHtmlFragment.jsx
Normal file
39
src/course-home/outline-tab/LmsHtmlFragment.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React, { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export default function LmsHtmlFragment({ html, title, ...rest }) {
|
||||
const wholePage = `
|
||||
<html>
|
||||
<head>
|
||||
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
|
||||
<link rel="stylesheet" href="/static/css/bootstrap/lms-main.css">
|
||||
</head>
|
||||
<body>${html}</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const iframe = useRef(null);
|
||||
function handleLoad() {
|
||||
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className="w-100 border-0"
|
||||
onLoad={handleLoad}
|
||||
ref={iframe}
|
||||
referrerPolicy="origin"
|
||||
scrolling="no"
|
||||
srcDoc={wholePage}
|
||||
title={title}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
LmsHtmlFragment.propTypes = {
|
||||
html: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
163
src/course-home/outline-tab/OutlineTab.jsx
Normal file
163
src/course-home/outline-tab/OutlineTab.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { AlertList } from '../../generic/user-messages';
|
||||
|
||||
import CourseDates from './widgets/CourseDates';
|
||||
import CourseGoalCard from './widgets/CourseGoalCard';
|
||||
import CourseHandouts from './widgets/CourseHandouts';
|
||||
import CourseTools from './widgets/CourseTools';
|
||||
import LearningToast from '../../toast/LearningToast';
|
||||
import messages from './messages';
|
||||
import Section from './Section';
|
||||
import UpdateGoalSelector from './widgets/UpdateGoalSelector';
|
||||
import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
|
||||
import useCertificateAvailableAlert from './alerts/certificate-available-alert';
|
||||
import useCourseEndAlert from './alerts/course-end-alert';
|
||||
import useCourseStartAlert from './alerts/course-start-alert';
|
||||
import useEnrollmentAlert from '../../alerts/enrollment-alert';
|
||||
import useLogistrationAlert from '../../alerts/logistration-alert';
|
||||
import useOfferAlert from '../../alerts/offer-alert';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import WelcomeMessage from './widgets/WelcomeMessage';
|
||||
|
||||
function OutlineTab({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
title,
|
||||
start,
|
||||
end,
|
||||
enrollmentStart,
|
||||
enrollmentEnd,
|
||||
enrollmentMode,
|
||||
isEnrolled,
|
||||
} = useModel('courses', courseId);
|
||||
|
||||
const {
|
||||
courseBlocks: {
|
||||
courses,
|
||||
sections,
|
||||
},
|
||||
courseGoals: {
|
||||
goalOptions,
|
||||
selectedGoal,
|
||||
},
|
||||
courseExpiredHtml,
|
||||
resumeCourse: {
|
||||
hasVisitedCourse,
|
||||
url: resumeCourseUrl,
|
||||
},
|
||||
offerHtml,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const [courseGoalToDisplay, setCourseGoalToDisplay] = useState(selectedGoal);
|
||||
const [goalToastHeader, setGoalToastHeader] = useState(null);
|
||||
|
||||
// Above the tab alerts (appearing in the order listed here)
|
||||
const logistrationAlert = useLogistrationAlert();
|
||||
const enrollmentAlert = useEnrollmentAlert(courseId);
|
||||
|
||||
// Below the course title alerts (appearing in the order listed here)
|
||||
const offerAlert = useOfferAlert(offerHtml, 'outline-course-alerts');
|
||||
const accessExpirationAlert = useAccessExpirationAlert(courseExpiredHtml, 'outline-course-alerts');
|
||||
const courseStartAlert = useCourseStartAlert(courseId);
|
||||
const courseEndAlert = useCourseEndAlert(courseId);
|
||||
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
|
||||
|
||||
const rootCourseId = Object.keys(courses)[0];
|
||||
const { sectionIds } = courses[rootCourseId];
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertList
|
||||
topic="outline"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
...enrollmentAlert,
|
||||
...logistrationAlert,
|
||||
}}
|
||||
/>
|
||||
<LearningToast
|
||||
header={goalToastHeader}
|
||||
onClose={() => setGoalToastHeader(null)}
|
||||
show={!!(goalToastHeader)}
|
||||
/>
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<div role="heading" aria-level="1" className="h4">{title}</div>
|
||||
{resumeCourseUrl && (
|
||||
<a className="btn btn-primary" href={resumeCourseUrl}>
|
||||
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col col-12 col-md-8">
|
||||
{!courseGoalToDisplay && goalOptions.length > 0 && (
|
||||
<CourseGoalCard
|
||||
courseId={courseId}
|
||||
goalOptions={goalOptions}
|
||||
title={title}
|
||||
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
|
||||
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
|
||||
/>
|
||||
)}
|
||||
<WelcomeMessage courseId={courseId} />
|
||||
<AlertList
|
||||
topic="outline-course-alerts"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
...accessExpirationAlert,
|
||||
...certificateAvailableAlert,
|
||||
...courseEndAlert,
|
||||
...courseStartAlert,
|
||||
...offerAlert,
|
||||
}}
|
||||
/>
|
||||
{sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={sectionId}
|
||||
courseId={courseId}
|
||||
section={sections[sectionId]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="col col-12 col-md-4">
|
||||
{courseGoalToDisplay && goalOptions.length > 0 && (
|
||||
<UpdateGoalSelector
|
||||
courseId={courseId}
|
||||
goalOptions={goalOptions}
|
||||
selectedGoal={courseGoalToDisplay}
|
||||
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
|
||||
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
|
||||
/>
|
||||
)}
|
||||
<CourseTools
|
||||
courseId={courseId}
|
||||
/>
|
||||
<CourseDates
|
||||
start={start}
|
||||
end={end}
|
||||
enrollmentStart={enrollmentStart}
|
||||
enrollmentEnd={enrollmentEnd}
|
||||
enrollmentMode={enrollmentMode}
|
||||
isEnrolled={isEnrolled}
|
||||
courseId={courseId}
|
||||
/>
|
||||
<CourseHandouts
|
||||
courseId={courseId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
OutlineTab.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(OutlineTab);
|
||||
46
src/course-home/outline-tab/Section.jsx
Normal file
46
src/course-home/outline-tab/Section.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Collapsible } from '@edx/paragon';
|
||||
import { faCheckCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import SequenceLink from './SequenceLink';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
export default function Section({ courseId, section }) {
|
||||
const {
|
||||
complete,
|
||||
sequenceIds,
|
||||
title,
|
||||
} = section;
|
||||
const {
|
||||
courseBlocks: {
|
||||
sequences,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const sectionTitle = (
|
||||
<div>
|
||||
{complete && <FontAwesomeIcon icon={faCheckCircle} className="float-left text-success mt-1" />}
|
||||
<div className="ml-4 font-weight-bold">{title}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible className="mb-2" styling="card-lg" title={sectionTitle} defaultOpen>
|
||||
{sequenceIds.map((sequenceId, index) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequenceId}
|
||||
courseId={courseId}
|
||||
sequence={sequences[sequenceId]}
|
||||
first={index === 0}
|
||||
/>
|
||||
))}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
Section.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
section: PropTypes.shape().isRequired,
|
||||
};
|
||||
110
src/course-home/outline-tab/SequenceLink.jsx
Normal file
110
src/course-home/outline-tab/SequenceLink.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FormattedMessage, FormattedTime } from '@edx/frontend-platform/i18n';
|
||||
import { faClock, faEdit } from '@fortawesome/free-regular-svg-icons';
|
||||
import {
|
||||
faCheck,
|
||||
faCheckCircle,
|
||||
faExclamationTriangle,
|
||||
faSpinner,
|
||||
faTimesCircle,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
export default function SequenceLink({
|
||||
id,
|
||||
courseId,
|
||||
first,
|
||||
sequence,
|
||||
}) {
|
||||
const {
|
||||
complete,
|
||||
description,
|
||||
due,
|
||||
icon,
|
||||
showLink,
|
||||
title,
|
||||
} = sequence;
|
||||
const {
|
||||
datesWidget: {
|
||||
userTimezone,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
let text = title;
|
||||
|
||||
let faIcon;
|
||||
switch (icon) {
|
||||
// list of possible ones here: https://github.com/edx/edx-proctoring/blob/master/edx_proctoring/api.py
|
||||
case 'fa-check': faIcon = faCheck; break;
|
||||
case 'fa-clock-o': faIcon = faClock; break;
|
||||
case 'fa-exclamation-triangle': faIcon = faExclamationTriangle; break;
|
||||
case 'fa-pencil-square-o': faIcon = faEdit; break;
|
||||
case 'fa-spinner fa-spin': faIcon = faSpinner; break;
|
||||
case 'fa-times-circle': faIcon = faTimesCircle; break;
|
||||
default: faIcon = null; break;
|
||||
}
|
||||
if (faIcon) {
|
||||
text = <><FontAwesomeIcon icon={faIcon} /> {text}</>;
|
||||
}
|
||||
|
||||
if (due) {
|
||||
text = (
|
||||
<>
|
||||
{text}<br />
|
||||
<small className="text-body">
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due"
|
||||
defaultMessage="{description} due {assignmentDue}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
hour12={false}
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
</small>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
text = <div className="ml-4">{text}</div>;
|
||||
|
||||
if (complete) {
|
||||
text = <><FontAwesomeIcon icon={faCheckCircle} className="float-left text-success mt-1" />{text}</>;
|
||||
}
|
||||
|
||||
// Do link last so we include everything above in the link
|
||||
if (showLink) {
|
||||
text = <Link to={`/course/${courseId}/${id}`}><div>{text}</div></Link>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SequenceLink.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
first: PropTypes.bool.isRequired,
|
||||
sequence: PropTypes.shape().isRequired,
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, FormattedRelative } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
|
||||
|
||||
function CertificateAvailableAlert({ payload }) {
|
||||
const {
|
||||
certDate,
|
||||
username,
|
||||
userTimezone,
|
||||
} = payload;
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.title"
|
||||
defaultMessage="We are working on generating course certificates."
|
||||
/>
|
||||
</strong>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.when"
|
||||
defaultMessage="If you have earned a certificate, you will be able to access it {timeRemaining}. You will also be able to view your certificates on your {profileLink}."
|
||||
values={{
|
||||
profileLink: (
|
||||
<Hyperlink
|
||||
destination={`${getConfig().LMS_BASE_URL}/u/${username}`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.profile"
|
||||
defaultMessage="Learner Profile"
|
||||
/>
|
||||
</Hyperlink>
|
||||
),
|
||||
timeRemaining: (
|
||||
<FormattedRelative
|
||||
key="timeRemaining"
|
||||
value={certDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
CertificateAvailableAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
certDate: PropTypes.string,
|
||||
username: PropTypes.string,
|
||||
userTimezone: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default CertificateAvailableAlert;
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { useAlert } from '../../../../generic/user-messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
const CertificateAvailableAlert = React.lazy(() => import('./CertificateAvailableAlert'));
|
||||
|
||||
function useCertificateAvailableAlert(courseId) {
|
||||
const {
|
||||
isEnrolled,
|
||||
} = useModel('courses', courseId);
|
||||
const {
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
userTimezone,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
const { username } = getAuthenticatedUser();
|
||||
|
||||
const certBlock = courseDateBlocks.find(b => b.dateType === 'certificate-available-date');
|
||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||
const endDate = endBlock ? new Date(endBlock.date) : null;
|
||||
const hasEnded = endBlock ? endDate < new Date() : false;
|
||||
const isVisible = isEnrolled && certBlock && hasEnded; // only show if we're between end and cert dates
|
||||
const payload = {
|
||||
certDate: certBlock && certBlock.date,
|
||||
username,
|
||||
userTimezone,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCertificateAvailableAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
return {
|
||||
clientCertificateAvailableAlert: CertificateAvailableAlert,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCertificateAvailableAlert;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './hooks';
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedMessage,
|
||||
FormattedRelative,
|
||||
FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
|
||||
|
||||
function CourseEndAlert({ payload }) {
|
||||
const {
|
||||
delta,
|
||||
description,
|
||||
endDate,
|
||||
userTimezone,
|
||||
} = payload;
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const timeRemaining = (
|
||||
<FormattedRelative
|
||||
key="timeRemaining"
|
||||
value={endDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
|
||||
let msg;
|
||||
if (delta < DAY_MS) {
|
||||
const courseEndTime = (
|
||||
<FormattedTime
|
||||
key="courseEndTime"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
hour12={false}
|
||||
timeZoneName="short"
|
||||
value={endDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
msg = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.short"
|
||||
defaultMessage="This course is ending {timeRemaining} at {courseEndTime}."
|
||||
description="Used when the time remaining is less than a day away."
|
||||
values={{
|
||||
courseEndTime,
|
||||
timeRemaining,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const courseEndDate = (
|
||||
<FormattedDate
|
||||
key="courseEndDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={endDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
msg = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.long"
|
||||
defaultMessage="This course is ending {timeRemaining} on {courseEndDate}."
|
||||
description="Used when the time remaining is more than a day away."
|
||||
values={{
|
||||
courseEndDate,
|
||||
timeRemaining,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<strong>{msg}</strong><br />
|
||||
{description}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
CourseEndAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
delta: PropTypes.number,
|
||||
description: PropTypes.string,
|
||||
endDate: PropTypes.string,
|
||||
userTimezone: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default CourseEndAlert;
|
||||
42
src/course-home/outline-tab/alerts/course-end-alert/hooks.js
Normal file
42
src/course-home/outline-tab/alerts/course-end-alert/hooks.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../../../generic/user-messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
const CourseEndAlert = React.lazy(() => import('./CourseEndAlert'));
|
||||
|
||||
// period of time (in ms) before end of course during which we alert
|
||||
const WARNING_PERIOD_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
|
||||
|
||||
export function useCourseEndAlert(courseId) {
|
||||
const {
|
||||
isEnrolled,
|
||||
} = useModel('courses', courseId);
|
||||
const {
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
userTimezone,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||
const endDate = endBlock ? new Date(endBlock.date) : null;
|
||||
const delta = endBlock ? endDate - new Date() : 0;
|
||||
const isVisible = isEnrolled && endBlock && delta > 0 && delta < WARNING_PERIOD_MS;
|
||||
const payload = {
|
||||
delta,
|
||||
description: endBlock && endBlock.description,
|
||||
endDate: endBlock && endBlock.date,
|
||||
userTimezone,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCourseEndAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
return {
|
||||
clientCourseEndAlert: CourseEndAlert,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useCourseEndAlert as default } from './hooks';
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedMessage,
|
||||
FormattedRelative,
|
||||
FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
|
||||
|
||||
function CourseStartAlert({ payload }) {
|
||||
const {
|
||||
delta,
|
||||
startDate,
|
||||
userTimezone,
|
||||
} = payload;
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const timeRemaining = (
|
||||
<FormattedRelative
|
||||
key="timeRemaining"
|
||||
value={startDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
|
||||
if (delta < DAY_MS) {
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.start.short"
|
||||
defaultMessage="Course starts {timeRemaining} at {courseStartTime}."
|
||||
description="Used when the time remaining is less than a day away."
|
||||
values={{
|
||||
courseStartTime: (
|
||||
<FormattedTime
|
||||
key="courseStartTime"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
hour12={false}
|
||||
timeZoneName="short"
|
||||
value={startDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
timeRemaining,
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.long"
|
||||
defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
|
||||
description="Used when the time remaining is more than a day away."
|
||||
values={{
|
||||
courseStartDate: (
|
||||
<FormattedDate
|
||||
key="courseStartDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={startDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
timeRemaining,
|
||||
}}
|
||||
/>
|
||||
</strong>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.calendar"
|
||||
defaultMessage="Don’t forget to add a calendar reminder!"
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
CourseStartAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
delta: PropTypes.number,
|
||||
startDate: PropTypes.string,
|
||||
userTimezone: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default CourseStartAlert;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { useAlert } from '../../../../generic/user-messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
|
||||
|
||||
function useCourseStartAlert(courseId) {
|
||||
const {
|
||||
isEnrolled,
|
||||
} = useModel('courses', courseId);
|
||||
const {
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
userTimezone,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const startBlock = courseDateBlocks.find(b => b.dateType === 'course-start-date');
|
||||
const delta = startBlock ? new Date(startBlock.date) - new Date() : 0;
|
||||
const isVisible = isEnrolled && startBlock && delta > 0;
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCourseStartAlert',
|
||||
payload: {
|
||||
delta,
|
||||
startDate: startBlock && startBlock.date,
|
||||
userTimezone,
|
||||
},
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
return {
|
||||
clientCourseStartAlert: CourseStartAlert,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCourseStartAlert;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './hooks';
|
||||
1
src/course-home/outline-tab/index.js
Normal file
1
src/course-home/outline-tab/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './OutlineTab';
|
||||
65
src/course-home/outline-tab/messages.js
Normal file
65
src/course-home/outline-tab/messages.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
allDates: {
|
||||
id: 'learning.outline.dates.all',
|
||||
defaultMessage: 'View all course dates',
|
||||
},
|
||||
dates: {
|
||||
id: 'learning.outline.dates',
|
||||
defaultMessage: 'Upcoming Dates',
|
||||
},
|
||||
editGoal: {
|
||||
id: 'learning.outline.editGoal',
|
||||
defaultMessage: 'Edit goal',
|
||||
description: 'Edit course goal button',
|
||||
},
|
||||
goal: {
|
||||
id: 'learning.outline.goal',
|
||||
defaultMessage: 'Goal',
|
||||
description: 'Label for the selected course goal',
|
||||
},
|
||||
goalUnsure: {
|
||||
id: 'learning.outline.goalUnsure',
|
||||
defaultMessage: 'Not sure yet',
|
||||
},
|
||||
goalWelcome: {
|
||||
id: 'learning.outline.goalWelcome',
|
||||
defaultMessage: 'Welcome to',
|
||||
description: 'This precedes the title of the course',
|
||||
},
|
||||
handouts: {
|
||||
id: 'learning.outline.handouts',
|
||||
defaultMessage: 'Course Handouts',
|
||||
},
|
||||
resume: {
|
||||
id: 'learning.outline.resume',
|
||||
defaultMessage: 'Resume Course',
|
||||
},
|
||||
setGoal: {
|
||||
id: 'learning.outline.setGoal',
|
||||
defaultMessage: 'To start, set a course goal by selecting the option below that best describes your learning plan.',
|
||||
},
|
||||
start: {
|
||||
id: 'learning.outline.start',
|
||||
defaultMessage: 'Start Course',
|
||||
},
|
||||
tools: {
|
||||
id: 'learning.outline.tools',
|
||||
defaultMessage: 'Course Tools',
|
||||
},
|
||||
welcomeMessage: {
|
||||
id: 'learning.outline.welcomeMessage',
|
||||
defaultMessage: 'Welcome Message',
|
||||
},
|
||||
welcomeMessageShowMoreButton: {
|
||||
id: 'learning.outline.welcomeMessageShowMoreButton',
|
||||
defaultMessage: 'Show More',
|
||||
},
|
||||
welcomeMessageShowLessButton: {
|
||||
id: 'learning.outline.welcomeMessageShowLessButton',
|
||||
defaultMessage: 'Show Less',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
41
src/course-home/outline-tab/widgets/CourseDates.jsx
Normal file
41
src/course-home/outline-tab/widgets/CourseDates.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import DateSummary from '../DateSummary';
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
function CourseDates({ courseId, intl }) {
|
||||
const {
|
||||
datesWidget,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
return (
|
||||
<section className="mb-3">
|
||||
<h2 className="h6">{intl.formatMessage(messages.dates)}</h2>
|
||||
{datesWidget.courseDateBlocks.map((courseDateBlock) => (
|
||||
<DateSummary
|
||||
key={courseDateBlock.title + courseDateBlock.date}
|
||||
dateBlock={courseDateBlock}
|
||||
userTimezone={datesWidget.userTimezone}
|
||||
/>
|
||||
))}
|
||||
<a className="font-weight-bold ml-4 pl-2" href={datesWidget.datesTabLink}>
|
||||
{intl.formatMessage(messages.allDates)}
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
CourseDates.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
CourseDates.defaultProps = {
|
||||
courseId: null,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseDates);
|
||||
94
src/course-home/outline-tab/widgets/CourseGoalCard.jsx
Normal file
94
src/course-home/outline-tab/widgets/CourseGoalCard.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, Card } from '@edx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
import { saveCourseGoal } from '../../data';
|
||||
|
||||
function CourseGoalCard({
|
||||
courseId,
|
||||
goalOptions,
|
||||
intl,
|
||||
title,
|
||||
setGoalToDisplay,
|
||||
setGoalToastHeader,
|
||||
}) {
|
||||
function selectGoalHandler(event) {
|
||||
const selectedGoal = {
|
||||
key: event.currentTarget.getAttribute('data-goal-key'),
|
||||
text: event.currentTarget.getAttribute('data-goal-text'),
|
||||
};
|
||||
|
||||
saveCourseGoal(courseId, selectedGoal.key).then((response) => {
|
||||
const { data } = response;
|
||||
const {
|
||||
header,
|
||||
} = data;
|
||||
|
||||
setGoalToDisplay(selectedGoal);
|
||||
setGoalToastHeader(header);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-3">
|
||||
<Card.Body>
|
||||
<div className="row w-100 m-0 justify-content-between align-items-center">
|
||||
<div className="col col-8 p-0">
|
||||
<Card.Title className="h6 m-0">{intl.formatMessage(messages.goalWelcome)} {title}</Card.Title>
|
||||
</div>
|
||||
<div className="col col-auto p-0">
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0"
|
||||
size="sm"
|
||||
block
|
||||
data-goal-key="unsure"
|
||||
data-goal-text={`${intl.formatMessage(messages.goalUnsure)}`}
|
||||
onClick={(event) => { selectGoalHandler(event); }}
|
||||
>
|
||||
{intl.formatMessage(messages.goalUnsure)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Card.Text className="my-2 mx-1">{intl.formatMessage(messages.setGoal)}</Card.Text>
|
||||
<div className="row w-100 m-0">
|
||||
{goalOptions.map((goal) => {
|
||||
const [goalKey, goalText] = goal;
|
||||
return (
|
||||
(goalKey !== 'unsure') && (
|
||||
<div key={`goal-${goalKey}`} className="col-auto flex-grow-1 mx-1 my-2 p-0">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
block
|
||||
data-goal-key={goalKey}
|
||||
data-goal-text={goalText}
|
||||
onClick={(event) => { selectGoalHandler(event); }}
|
||||
>
|
||||
{goalText}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
CourseGoalCard.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
goalOptions: PropTypes.arrayOf(
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
setGoalToDisplay: PropTypes.func.isRequired,
|
||||
setGoalToastHeader: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseGoalCard);
|
||||
35
src/course-home/outline-tab/widgets/CourseHandouts.jsx
Normal file
35
src/course-home/outline-tab/widgets/CourseHandouts.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import LmsHtmlFragment from '../LmsHtmlFragment';
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
function CourseHandouts({ courseId, intl }) {
|
||||
const {
|
||||
handoutsHtml,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
if (!handoutsHtml) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-3">
|
||||
<h2 className="h6">{intl.formatMessage(messages.handouts)}</h2>
|
||||
<LmsHtmlFragment
|
||||
html={handoutsHtml}
|
||||
title={intl.formatMessage(messages.handouts)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
CourseHandouts.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseHandouts);
|
||||
73
src/course-home/outline-tab/widgets/CourseTools.jsx
Normal file
73
src/course-home/outline-tab/widgets/CourseTools.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBookmark, faCertificate, faInfo, faCalendar, faStar,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { faNewspaper } from '@fortawesome/free-regular-svg-icons';
|
||||
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
function CourseTools({ courseId, intl }) {
|
||||
const {
|
||||
courseTools,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const logClick = (analyticsId) => {
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
sendTrackEvent('edx.course.tool.accessed', {
|
||||
course_id: courseId,
|
||||
is_staff: administrator,
|
||||
tool_name: analyticsId,
|
||||
});
|
||||
};
|
||||
|
||||
const renderIcon = (iconClasses) => {
|
||||
switch (iconClasses) {
|
||||
case 'edx.bookmarks':
|
||||
return faBookmark;
|
||||
case 'edx.tool.verified_upgrade':
|
||||
return faCertificate;
|
||||
case 'edx.tool.financial_assistance':
|
||||
return faInfo;
|
||||
case 'edx.calendar-sync':
|
||||
return faCalendar;
|
||||
case 'edx.updates':
|
||||
return faNewspaper;
|
||||
case 'edx.reviews':
|
||||
return faStar;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mb-3">
|
||||
<h2 className="h6">{intl.formatMessage(messages.tools)}</h2>
|
||||
{courseTools.map((courseTool) => (
|
||||
<div key={courseTool.analyticsId}>
|
||||
<a href={courseTool.url} onClick={() => logClick(courseTool.analyticsId)}>
|
||||
<FontAwesomeIcon icon={renderIcon(courseTool.analyticsId)} className="mr-2" style={{ width: '20px' }} />
|
||||
{courseTool.title}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
CourseTools.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
CourseTools.defaultProps = {
|
||||
courseId: null,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseTools);
|
||||
107
src/course-home/outline-tab/widgets/UpdateGoalSelector.jsx
Normal file
107
src/course-home/outline-tab/widgets/UpdateGoalSelector.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Card, Input } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import messages from '../messages';
|
||||
import { saveCourseGoal } from '../../data';
|
||||
|
||||
function UpdateGoalSelector({
|
||||
courseId,
|
||||
goalOptions,
|
||||
intl,
|
||||
selectedGoal,
|
||||
setGoalToDisplay,
|
||||
setGoalToastHeader,
|
||||
}) {
|
||||
const [editingGoal, setEditingGoal] = useState(false);
|
||||
|
||||
function selectGoalHandler(event) {
|
||||
const key = event.currentTarget.value;
|
||||
const { options } = event.currentTarget;
|
||||
const { text } = options[options.selectedIndex];
|
||||
const newGoal = {
|
||||
key,
|
||||
text,
|
||||
};
|
||||
|
||||
setEditingGoal(false);
|
||||
setGoalToDisplay(newGoal);
|
||||
saveCourseGoal(courseId, key).then((response) => {
|
||||
const { data } = response;
|
||||
const {
|
||||
header,
|
||||
} = data;
|
||||
|
||||
setGoalToastHeader(header);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="mb-3">
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-12 p-0">
|
||||
<label className="h6" htmlFor="edit-goal-selector">
|
||||
{intl.formatMessage(messages.goal)}
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-12 p-0">
|
||||
<Card>
|
||||
<Card.Body className="px-3 py-2">
|
||||
<div className="row w-100 m-0 justify-content-between align-items-center">
|
||||
<div className="col-10 p-0">
|
||||
{!editingGoal && (
|
||||
<p className="m-0">{selectedGoal.text}</p>
|
||||
)}
|
||||
{editingGoal && (
|
||||
<Input
|
||||
id="edit-goal-selector"
|
||||
type="select"
|
||||
defaultValue={selectedGoal.key}
|
||||
onBlur={() => { setEditingGoal(false); }}
|
||||
onChange={(event) => { selectGoalHandler(event); }}
|
||||
options={goalOptions.map(([goalKey, goalText]) => (
|
||||
{ value: goalKey, label: goalText }
|
||||
))}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
aria-label={intl.formatMessage(messages.editGoal)}
|
||||
className="p-1"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => { setEditingGoal(true); }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencilAlt} />
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
UpdateGoalSelector.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
goalOptions: PropTypes.arrayOf(
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
selectedGoal: PropTypes.shape({
|
||||
key: PropTypes.string,
|
||||
text: PropTypes.string,
|
||||
}).isRequired,
|
||||
setGoalToDisplay: PropTypes.func.isRequired,
|
||||
setGoalToastHeader: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(UpdateGoalSelector);
|
||||
68
src/course-home/outline-tab/widgets/WelcomeMessage.jsx
Normal file
68
src/course-home/outline-tab/widgets/WelcomeMessage.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import LmsHtmlFragment from '../LmsHtmlFragment';
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { Alert } from '../../../generic/user-messages';
|
||||
import { dismissWelcomeMessage } from '../../data/thunks';
|
||||
|
||||
function WelcomeMessage({ courseId, intl }) {
|
||||
const {
|
||||
welcomeMessageHtml,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
if (!welcomeMessageHtml) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [display, setDisplay] = useState(true);
|
||||
|
||||
const shortWelcomeMessageHtml = welcomeMessageHtml.length > 200 && `${welcomeMessageHtml.substring(0, 199)}...`;
|
||||
const [showShortMessage, setShowShortMessage] = useState(!!shortWelcomeMessageHtml);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
display && (
|
||||
<Alert
|
||||
type="welcome"
|
||||
dismissible
|
||||
onDismiss={() => {
|
||||
setDisplay(false);
|
||||
dispatch(dismissWelcomeMessage(courseId));
|
||||
}}
|
||||
>
|
||||
<div className="my-3">
|
||||
<LmsHtmlFragment
|
||||
html={showShortMessage ? shortWelcomeMessageHtml : welcomeMessageHtml}
|
||||
title={intl.formatMessage(messages.welcomeMessage)}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
shortWelcomeMessageHtml && (
|
||||
<div className="d-flex justify-content-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn rounded align-self-center border border-primary bg-white font-weight-bold mb-3"
|
||||
onClick={() => setShowShortMessage(!showShortMessage)}
|
||||
>
|
||||
{showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton)
|
||||
: intl.formatMessage(messages.welcomeMessageShowLessButton)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Alert>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
WelcomeMessage.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(WelcomeMessage);
|
||||
74
src/course-home/progress-tab/CertificateBanner.jsx
Normal file
74
src/course-home/progress-tab/CertificateBanner.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { requestCert } from '../data/thunks';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
import VerifiedCert from '../../courseware/course/sequence/lock-paywall/assets/edx-verified-mini-cert.png';
|
||||
|
||||
function CertificateBanner({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
certificateData,
|
||||
enrollmentMode,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
if (certificateData === null || enrollmentMode === 'audit') { return null; }
|
||||
const { certUrl, certDownloadUrl } = certificateData;
|
||||
const dispatch = useDispatch();
|
||||
function requestHandler() {
|
||||
dispatch(requestCert(courseId));
|
||||
}
|
||||
return (
|
||||
<section className="banner rounded my-4 p-4 container-fluid border border-primary-200 bg-info-100 row">
|
||||
<div className="col-12 col-sm-9">
|
||||
<div>
|
||||
<div className="font-weight-bold">{certificateData.title}</div>
|
||||
<div className="mt-1">{certificateData.msg}</div>
|
||||
</div>
|
||||
{certUrl && (
|
||||
<div>
|
||||
<a className="btn btn-primary my-3" href={certUrl} rel="noopener noreferrer" target="_blank">
|
||||
{intl.formatMessage(messages.viewCert)}
|
||||
<span className="sr-only">{intl.formatMessage(messages.opensNewWindow)}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{!certUrl && certificateData.isDownloadable && (
|
||||
<div>
|
||||
<a className="btn btn-primary my-3" href={certDownloadUrl} rel="noopener noreferrer" target="_blank">
|
||||
{intl.formatMessage(messages.downloadCert)}
|
||||
<span className="sr-only">{intl.formatMessage(messages.opensNewWindow)}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{!certUrl && !certificateData.isDownloadable && certificateData.isRequestable && (
|
||||
<div className="my-3">
|
||||
<button className="btn btn-primary" type="button" onClick={requestHandler}>
|
||||
{intl.formatMessage(messages.requestCert)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-0 col-sm-3 d-none d-sm-block">
|
||||
<img
|
||||
alt={intl.formatMessage(messages.certAlt)}
|
||||
src={VerifiedCert}
|
||||
className="float-right"
|
||||
style={{ height: '120px' }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
CertificateBanner.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CertificateBanner);
|
||||
36
src/course-home/progress-tab/Chapter.jsx
Normal file
36
src/course-home/progress-tab/Chapter.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Subsection from './Subsection';
|
||||
|
||||
export default function Chapter({
|
||||
chapter,
|
||||
}) {
|
||||
if (chapter.displayName === 'hidden') { return null; }
|
||||
const { subsections } = chapter;
|
||||
return (
|
||||
<section className="border-top border-light-500">
|
||||
<div className="row">
|
||||
<div className="lead font-weight-normal col-12 col-sm-3 my-3 border-right border-light-500">
|
||||
{chapter.displayName}
|
||||
</div>
|
||||
<div className="col-12 col-sm-9">
|
||||
{subsections.map((subsection) => (
|
||||
<Subsection
|
||||
key={subsection.url}
|
||||
subsection={subsection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Chapter.propTypes = {
|
||||
chapter: PropTypes.shape({
|
||||
displayName: PropTypes.string,
|
||||
subsections: PropTypes.arrayOf(PropTypes.shape({
|
||||
url: PropTypes.string,
|
||||
})),
|
||||
}).isRequired,
|
||||
};
|
||||
151
src/course-home/progress-tab/CreditRequirements.jsx
Normal file
151
src/course-home/progress-tab/CreditRequirements.jsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
FormattedDate, FormattedTime, injectIntl, intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
function CreditRequirements({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
creditCourseRequirements,
|
||||
creditSupportUrl,
|
||||
verificationData,
|
||||
userTimezone,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
if (creditCourseRequirements === null) { return null; }
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
const eligibility = creditCourseRequirements.eligibilityStatus;
|
||||
let message;
|
||||
switch (eligibility) {
|
||||
case 'not_eligible':
|
||||
message = intl.formatMessage(messages.creditNotEligible);
|
||||
break;
|
||||
case 'eligible':
|
||||
message = intl.formatMessage(messages.creditEligible);
|
||||
break;
|
||||
case 'partial_eligible':
|
||||
message = intl.formatMessage(messages.creditPartialEligible);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const completed = `✓ ${intl.formatMessage(messages.completed)} `;
|
||||
|
||||
const { status } = verificationData;
|
||||
let verificationMessage;
|
||||
let verificationLinkMessage = '';
|
||||
|
||||
switch (status) {
|
||||
case 'none':
|
||||
case 'expired':
|
||||
verificationMessage = `${intl.formatMessage(messages.notStarted)}; `;
|
||||
verificationLinkMessage = intl.formatMessage(messages.notStarted);
|
||||
break;
|
||||
case 'approved':
|
||||
verificationMessage = completed;
|
||||
break;
|
||||
case 'pending':
|
||||
verificationMessage = intl.formatMessage(messages.pending);
|
||||
break;
|
||||
case 'must_reverify':
|
||||
verificationMessage = `${intl.formatMessage(messages.rejected)}; `;
|
||||
verificationLinkMessage = intl.formatMessage(messages.tryAgain);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<section className="banner rounded row border border-primary-300 my-2">
|
||||
<div className="col ml-4 my-3">
|
||||
<div className="row font-weight-bold">
|
||||
{intl.formatMessage(messages.courseCreditHeader)}
|
||||
</div>
|
||||
<div className="row mb-2">{message}</div>
|
||||
{creditCourseRequirements.requirements.map((requirement) => (
|
||||
<div key={requirement.displayName} className="row w-50 border-bottom">
|
||||
<div className="col-4">
|
||||
{requirement.displayName}
|
||||
{requirement.minGrade && (
|
||||
<span>{` ${requirement.minGrade}%`}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-8">
|
||||
{!requirement.status && (
|
||||
intl.formatMessage(messages.notMet)
|
||||
)}
|
||||
{(requirement.status === 'failed' || requirement.status === 'declined') && (
|
||||
intl.formatMessage(messages.failed)
|
||||
)}
|
||||
{requirement.status === 'submitted' && (
|
||||
intl.formatMessage(messages.submitted)
|
||||
)}
|
||||
{requirement.status === 'satisfied' && (
|
||||
<span>
|
||||
{completed}
|
||||
{requirement.statusDate && (
|
||||
<span>
|
||||
<FormattedDate
|
||||
value={requirement.statusDate}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
year="numeric"
|
||||
{...timezoneFormatArgs}
|
||||
/> <FormattedTime
|
||||
value={requirement.statusDate}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="row w-50 border-bottom">
|
||||
<div className="col-4">Verification Status </div>
|
||||
<div className="col-8">
|
||||
{verificationMessage}
|
||||
{verificationLinkMessage && (
|
||||
<a href={verificationData.link}>{verificationLinkMessage}</a>
|
||||
)}
|
||||
{status === 'approved' && verificationData.statusDate && (
|
||||
<span>
|
||||
<FormattedDate
|
||||
value={verificationData.statusDate}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
year="numeric"
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{eligibility === 'eligible' && (
|
||||
<div className="mt-3 row">
|
||||
<a className="btn btn-primary" href={creditCourseRequirements.dashboardUrl}>{intl.formatMessage(messages.purchaseCredit)}</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 row">
|
||||
<a href={creditSupportUrl}>{intl.formatMessage(messages.learnMoreCredit)}</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
CreditRequirements.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CreditRequirements);
|
||||
36
src/course-home/progress-tab/DueDateTime.jsx
Normal file
36
src/course-home/progress-tab/DueDateTime.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { FormattedDate, FormattedTime } from '@edx/frontend-platform/i18n';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
export default function DueDateTime({
|
||||
due,
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
const {
|
||||
userTimezone,
|
||||
} = useModel('progress', courseId);
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
return (
|
||||
<em className="ml-0">
|
||||
due <FormattedDate
|
||||
value={due}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
year="numeric"
|
||||
{...timezoneFormatArgs}
|
||||
/> <FormattedTime
|
||||
value={due}
|
||||
/>
|
||||
</em>
|
||||
);
|
||||
}
|
||||
|
||||
DueDateTime.propTypes = {
|
||||
due: PropTypes.string.isRequired,
|
||||
};
|
||||
37
src/course-home/progress-tab/ProblemScores.jsx
Normal file
37
src/course-home/progress-tab/ProblemScores.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
function ProblemScores({
|
||||
intl,
|
||||
scoreName,
|
||||
problemScores,
|
||||
}) {
|
||||
return (
|
||||
<div className="row mt-1">
|
||||
<dl className="d-flex flex-wrap small text-gray-500">
|
||||
<dt className="mr-3">{intl.formatMessage(messages[`${scoreName}`])}</dt>
|
||||
{problemScores.map((problem, index) => {
|
||||
const key = scoreName + index;
|
||||
return (
|
||||
<dd className="mr-3" key={key}>{problem.earned}/{problem.possible}</dd>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ProblemScores.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
scoreName: PropTypes.string.isRequired,
|
||||
problemScores: PropTypes.arrayOf(PropTypes.shape({
|
||||
possible: PropTypes.number,
|
||||
earned: PropTypes.number,
|
||||
id: PropTypes.string,
|
||||
})).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ProblemScores);
|
||||
0
src/course-home/progress-tab/ProgressGraph.jsx
Normal file
0
src/course-home/progress-tab/ProgressGraph.jsx
Normal file
48
src/course-home/progress-tab/ProgressTab.jsx
Normal file
48
src/course-home/progress-tab/ProgressTab.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import Chapter from './Chapter';
|
||||
import CertificateBanner from './CertificateBanner';
|
||||
import messages from './messages';
|
||||
import CreditRequirements from './CreditRequirements';
|
||||
|
||||
function ProgressTab({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
const {
|
||||
coursewareSummary,
|
||||
studioUrl,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
return (
|
||||
<section>
|
||||
{administrator && studioUrl && (
|
||||
<div className="row mb-3 mr-3 justify-content-end">
|
||||
<a className="btn-sm border border-info" href={studioUrl}>
|
||||
{intl.formatMessage(messages.studioLink)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<CertificateBanner />
|
||||
<CreditRequirements />
|
||||
{coursewareSummary.map((chapter) => (
|
||||
<Chapter
|
||||
key={chapter.displayName}
|
||||
chapter={chapter}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
ProgressTab.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ProgressTab);
|
||||
77
src/course-home/progress-tab/Subsection.jsx
Normal file
77
src/course-home/progress-tab/Subsection.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
import DueDateTime from './DueDateTime';
|
||||
import ProblemScores from './ProblemScores';
|
||||
|
||||
function Subsection({
|
||||
intl,
|
||||
subsection,
|
||||
}) {
|
||||
const scoreName = subsection.graded ? 'problem' : 'practice';
|
||||
|
||||
const { earned, possible } = subsection.gradedTotal;
|
||||
|
||||
const showTotalScore = ((possible > 0) || (earned > 0)) && subsection.showGrades;
|
||||
|
||||
// screen reader information
|
||||
const totalScoreSr = intl.formatMessage(messages.pointsEarned, { earned, total: possible });
|
||||
|
||||
return (
|
||||
<section className="my-3 ml-3">
|
||||
<div className="row">
|
||||
<a className="h6" href={subsection.url}>
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<div dangerouslySetInnerHTML={{ __html: subsection.displayName }} />
|
||||
{showTotalScore && <span className="sr-only">{totalScoreSr}</span>}
|
||||
</a>
|
||||
{showTotalScore && <span className="small ml-1 mb-2">({earned}/{possible}) {subsection.percentGraded}%</span>}
|
||||
</div>
|
||||
<div className="row small">
|
||||
{subsection.format && <div className="mr-1">{subsection.format}</div>}
|
||||
{subsection.due !== null && <DueDateTime due={subsection.due} />}
|
||||
</div>
|
||||
{subsection.problemScores.length > 0 && subsection.showGrades && (
|
||||
<ProblemScores scoreName={scoreName} problemScores={subsection.problemScores} />
|
||||
)}
|
||||
{subsection.problemScores.length > 0 && !subsection.showGrades && subsection.showCorrectness === 'past_due' && (
|
||||
<div className="row small">{intl.formatMessage(messages[`${scoreName}HiddenUntil`])}</div>
|
||||
)}
|
||||
{subsection.problemScores.length > 0 && !subsection.showGrades && !(subsection.showCorrectness === 'past_due')
|
||||
&& <div className="row small">{intl.formatMessage(messages[`${scoreName}Hidden`])}</div>}
|
||||
{(subsection.problemScores.length === 0) && (
|
||||
<div className="row small">{intl.formatMessage(messages.noScores)}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Subsection.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
subsection: PropTypes.shape({
|
||||
graded: PropTypes.bool.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
showGrades: PropTypes.bool.isRequired,
|
||||
gradedTotal: PropTypes.shape({
|
||||
possible: PropTypes.number,
|
||||
earned: PropTypes.number,
|
||||
graded: PropTypes.bool,
|
||||
}).isRequired,
|
||||
showCorrectness: PropTypes.string.isRequired,
|
||||
due: PropTypes.string,
|
||||
problemScores: PropTypes.arrayOf(PropTypes.shape({
|
||||
possible: PropTypes.number,
|
||||
earned: PropTypes.number,
|
||||
id: PropTypes.string,
|
||||
})).isRequired,
|
||||
format: PropTypes.string,
|
||||
// override: PropTypes.object,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
percentGraded: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Subsection);
|
||||
123
src/course-home/progress-tab/messages.js
Normal file
123
src/course-home/progress-tab/messages.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
problem: {
|
||||
id: 'learning.progress.badge.problem',
|
||||
defaultMessage: 'Problem Scores: ',
|
||||
},
|
||||
practice: {
|
||||
id: 'learning.progress.badge.practice',
|
||||
defaultMessage: 'Practice Scores: ',
|
||||
},
|
||||
problemHiddenUntil: {
|
||||
id: 'learning.progress.badge.problemHiddenUntil',
|
||||
defaultMessage: 'Problem scores are hidden until the due date.',
|
||||
},
|
||||
practiceHiddenUntil: {
|
||||
id: 'learning.progress.badge.practiceHiddenUntil',
|
||||
defaultMessage: 'Practice scores are hidden until the due date.',
|
||||
},
|
||||
problemHidden: {
|
||||
id: 'learning.progress.badge.probHidden',
|
||||
defaultMessage: 'problemlem scores are hidden.',
|
||||
},
|
||||
practiceHidden: {
|
||||
id: 'learning.progress.badge.practiceHidden',
|
||||
defaultMessage: 'Practice scores are hidden.',
|
||||
},
|
||||
noScores: {
|
||||
id: 'learning.progress.badge.noScores',
|
||||
defaultMessage: 'No problem scores in this section.',
|
||||
},
|
||||
pointsEarned: {
|
||||
id: 'learning.progress.badge.scoreEarned',
|
||||
defaultMessage: '{earned} of {total} possible points',
|
||||
},
|
||||
viewCert: {
|
||||
id: 'learning.progress.badge.viewCert',
|
||||
defaultMessage: 'View Certificate',
|
||||
},
|
||||
downloadCert: {
|
||||
id: 'learning.progress.badge.downloadCert',
|
||||
defaultMessage: 'Download Your Certificate',
|
||||
},
|
||||
requestCert: {
|
||||
id: 'learning.progress.badge.requestCert',
|
||||
defaultMessage: 'Request Certificate',
|
||||
},
|
||||
opensNewWindow: {
|
||||
id: 'learning.progress.badge.opensNewWindow',
|
||||
defaultMessage: 'Opens in a new browser window',
|
||||
},
|
||||
certAlt: {
|
||||
id: 'learning.progress.badge.certAlt',
|
||||
defaultMessage: 'Example Certificate',
|
||||
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
|
||||
},
|
||||
studioLink: {
|
||||
id: 'learning.progress.badge.studioLink',
|
||||
defaultMessage: 'View grading in Studio',
|
||||
},
|
||||
courseCreditHeader: {
|
||||
id: 'learning.progress.courseCreditHeader',
|
||||
defaultMessage: 'Course Credit Eligibility',
|
||||
},
|
||||
creditNotEligible: {
|
||||
id: 'learning.progress.creditNotEligible',
|
||||
defaultMessage: 'You are not eligible for course credit because you have not met the requirements for credit.',
|
||||
},
|
||||
creditEligible: {
|
||||
id: 'learning.progress.creditEligible',
|
||||
defaultMessage: 'You have met the requirements for credit in this course.',
|
||||
},
|
||||
creditPartialEligible: {
|
||||
id: 'learning.progress.creditPartialEligible',
|
||||
defaultMessage: 'You have not met the minimum requirements for credit.',
|
||||
},
|
||||
start: {
|
||||
id: 'learning.progress.startVerification',
|
||||
defaultMessage: 'Start now',
|
||||
},
|
||||
tryAgain: {
|
||||
id: 'learning.progress.start',
|
||||
defaultMessage: 'Try again',
|
||||
},
|
||||
notStarted: {
|
||||
id: 'learning.progress.notStarted',
|
||||
defaultMessage: 'Not started',
|
||||
},
|
||||
failed: {
|
||||
id: 'learning.progress.failed',
|
||||
defaultMessage: 'Incomplete',
|
||||
},
|
||||
notMet: {
|
||||
id: 'learning.progress.notMet',
|
||||
defaultMessage: 'Not met',
|
||||
},
|
||||
pending: {
|
||||
id: 'learning.progress.pending',
|
||||
defaultMessage: 'Pending',
|
||||
},
|
||||
rejected: {
|
||||
id: 'learning.progress.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
},
|
||||
completed: {
|
||||
id: 'learning.progress.completed',
|
||||
defaultMessage: 'Completed',
|
||||
},
|
||||
submitted: {
|
||||
id: 'learning.progress.submitted',
|
||||
defaultMessage: 'Submitted',
|
||||
},
|
||||
learnMoreCredit: {
|
||||
id: 'learning.progress.learnMoreCredit',
|
||||
defaultMessage: 'Learn more about course credit',
|
||||
},
|
||||
purchaseCredit: {
|
||||
id: 'learning.progress.purchaseCredit',
|
||||
defaultMessage: 'Purchase course credit',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,185 +1,267 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { getLocale } from '@edx/frontend-platform/i18n';
|
||||
import { Redirect } from 'react-router';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { defaultMemoize as memoize } from 'reselect';
|
||||
|
||||
import { useRouteMatch, Redirect } from 'react-router';
|
||||
import {
|
||||
fetchCourse,
|
||||
fetchSequence,
|
||||
} from '../data';
|
||||
import {
|
||||
checkBlockCompletion,
|
||||
fetchCourse,
|
||||
fetchSequence,
|
||||
getResumeBlock,
|
||||
saveSequencePosition,
|
||||
} from './data/thunks';
|
||||
import { useModel } from '../model-store';
|
||||
} from './data';
|
||||
import { TabPage } from '../tab-page';
|
||||
|
||||
import Course from './course';
|
||||
import { handleNextSectionCelebration } from './course/celebration';
|
||||
|
||||
import { sequenceIdsSelector, firstSequenceIdSelector } from './data/selectors';
|
||||
|
||||
function useUnitNavigationHandler(courseId, sequenceId, unitId) {
|
||||
const dispatch = useDispatch();
|
||||
return useCallback((nextUnitId) => {
|
||||
dispatch(checkBlockCompletion(courseId, sequenceId, unitId));
|
||||
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
|
||||
}, [courseId, sequenceId]);
|
||||
}
|
||||
|
||||
function usePreviousSequence(sequenceId) {
|
||||
const sequenceIds = useSelector(sequenceIdsSelector);
|
||||
const sequences = useSelector(state => state.models.sequences);
|
||||
if (!sequenceId || sequenceIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sequenceIndex = sequenceIds.indexOf(sequenceId);
|
||||
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
|
||||
return previousSequenceId !== null ? sequences[previousSequenceId] : null;
|
||||
}
|
||||
|
||||
function useNextSequence(sequenceId) {
|
||||
const sequenceIds = useSelector(sequenceIdsSelector);
|
||||
const sequences = useSelector(state => state.models.sequences);
|
||||
if (!sequenceId || sequenceIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sequenceIndex = sequenceIds.indexOf(sequenceId);
|
||||
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
|
||||
return nextSequenceId !== null ? sequences[nextSequenceId] : null;
|
||||
}
|
||||
|
||||
|
||||
function useNextSequenceHandler(courseId, sequenceId) {
|
||||
const nextSequence = useNextSequence(sequenceId);
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
return useCallback(() => {
|
||||
if (nextSequence !== null) {
|
||||
const nextUnitId = nextSequence.unitIds[0];
|
||||
history.push(`/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
|
||||
}
|
||||
}, [courseStatus, sequenceStatus, sequenceId]);
|
||||
}
|
||||
|
||||
function usePreviousSequenceHandler(courseId, sequenceId) {
|
||||
const previousSequence = usePreviousSequence(sequenceId);
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
return useCallback(() => {
|
||||
if (previousSequence !== null) {
|
||||
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
|
||||
history.push(`/course/${courseId}/${previousSequence.id}/${previousUnitId}`);
|
||||
}
|
||||
}, [courseStatus, sequenceStatus, sequenceId]);
|
||||
}
|
||||
|
||||
function useExamRedirect(sequenceId) {
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
useEffect(() => {
|
||||
if (sequenceStatus === 'loaded' && sequence.isTimeLimited) {
|
||||
const checkExamRedirect = memoize((sequenceStatus, sequence) => {
|
||||
if (sequenceStatus === 'loaded') {
|
||||
if (sequence.isTimeLimited && sequence.lmsWebUrl !== undefined) {
|
||||
global.location.assign(sequence.lmsWebUrl);
|
||||
}
|
||||
}, [sequenceStatus, sequence]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function useContentRedirect(courseStatus, sequenceStatus) {
|
||||
const match = useRouteMatch();
|
||||
const { courseId, sequenceId, unitId } = match.params;
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const firstSequenceId = useSelector(firstSequenceIdSelector);
|
||||
useEffect(() => {
|
||||
if (courseStatus === 'loaded' && !sequenceId) {
|
||||
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
|
||||
if (courseStatus === 'loaded' && !sequenceId) {
|
||||
// Note that getResumeBlock is just an API call, not a redux thunk.
|
||||
getResumeBlock(courseId).then((data) => {
|
||||
// This is a replace because we don't want this change saved in the browser's history.
|
||||
history.replace(`/course/${courseId}/${firstSequenceId}`);
|
||||
}
|
||||
}, [courseStatus, sequenceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sequenceStatus === 'loaded' && sequenceId && !unitId) {
|
||||
// The position may be null, in which case we'll just assume 0.
|
||||
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
|
||||
const unitIndex = sequence.position || 0;
|
||||
const nextUnitId = sequence.unitIds[unitIndex];
|
||||
// This is a replace because we don't want this change saved in the browser's history.
|
||||
history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
|
||||
if (data.sectionId && data.unitId) {
|
||||
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
|
||||
} else if (firstSequenceId) {
|
||||
history.replace(`/course/${courseId}/${firstSequenceId}`);
|
||||
}
|
||||
}
|
||||
}, [sequenceStatus, sequenceId, unitId]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function useSavedSequencePosition(courseId, sequenceId, unitId) {
|
||||
const dispatch = useDispatch();
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
useEffect(() => {
|
||||
if (sequenceStatus === 'loaded' && sequence.savePosition) {
|
||||
const checkContentRedirect = memoize((courseId, sequenceStatus, sequenceId, sequence, unitId) => {
|
||||
if (sequenceStatus === 'loaded' && sequenceId && !unitId) {
|
||||
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
|
||||
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
|
||||
// This is a replace because we don't want this change saved in the browser's history.
|
||||
history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
class CoursewareContainer extends Component {
|
||||
checkSaveSequencePosition = memoize((unitId) => {
|
||||
const {
|
||||
courseId,
|
||||
sequenceId,
|
||||
sequenceStatus,
|
||||
sequence,
|
||||
} = this.props;
|
||||
if (sequenceStatus === 'loaded' && sequence.saveUnitPosition && unitId) {
|
||||
const activeUnitIndex = sequence.unitIds.indexOf(unitId);
|
||||
dispatch(saveSequencePosition(courseId, sequenceId, activeUnitIndex));
|
||||
this.props.saveSequencePosition(courseId, sequenceId, activeUnitIndex);
|
||||
}
|
||||
}, [unitId]);
|
||||
}
|
||||
});
|
||||
|
||||
export default function CoursewareContainer() {
|
||||
const { params } = useRouteMatch();
|
||||
const {
|
||||
courseId: routeCourseUsageKey,
|
||||
sequenceId: routeSequenceId,
|
||||
unitId: routeUnitId,
|
||||
} = params;
|
||||
const dispatch = useDispatch();
|
||||
checkFetchCourse = memoize((courseId) => {
|
||||
this.props.fetchCourse(courseId);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourse(routeCourseUsageKey));
|
||||
}, [routeCourseUsageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (routeSequenceId) {
|
||||
dispatch(fetchSequence(routeSequenceId));
|
||||
checkFetchSequence = memoize((sequenceId) => {
|
||||
if (sequenceId) {
|
||||
this.props.fetchSequence(sequenceId);
|
||||
}
|
||||
}, [routeSequenceId]);
|
||||
});
|
||||
|
||||
// The courseId and sequenceId in the store are the entities we currently have loaded.
|
||||
// We get these two IDs from the store because until fetchCourse and fetchSequence below have
|
||||
// finished their work, the IDs in the URL are not representative of what we should actually show.
|
||||
// This is important particularly when switching sequences. Until a new sequence is fully loaded,
|
||||
// there's information that we don't have yet - if we use the URL's sequence ID to tell the app
|
||||
// which sequence is loaded, we'll instantly try to pull it out of the store and use it, before
|
||||
// the sequenceStatus flag has even switched back to "loading", which will put our app into an
|
||||
// invalid state.
|
||||
const {
|
||||
courseId,
|
||||
sequenceId,
|
||||
courseStatus,
|
||||
sequenceStatus,
|
||||
} = useSelector(state => state.courseware);
|
||||
|
||||
const nextSequenceHandler = useNextSequenceHandler(courseId, sequenceId);
|
||||
const previousSequenceHandler = usePreviousSequenceHandler(courseId, sequenceId);
|
||||
const unitNavigationHandler = useUnitNavigationHandler(courseId, sequenceId, routeUnitId);
|
||||
|
||||
useContentRedirect(courseStatus, sequenceStatus);
|
||||
useExamRedirect(sequenceId);
|
||||
useSavedSequencePosition(courseId, sequenceId, routeUnitId);
|
||||
|
||||
if (courseStatus === 'denied') {
|
||||
return <Redirect to={`/redirect/course-home/${courseId}`} />;
|
||||
componentDidMount() {
|
||||
const {
|
||||
match: {
|
||||
params: {
|
||||
courseId: routeCourseId,
|
||||
sequenceId: routeSequenceId,
|
||||
},
|
||||
},
|
||||
} = this.props;
|
||||
// Load data whenever the course or sequence ID changes.
|
||||
this.checkFetchCourse(routeCourseId);
|
||||
this.checkFetchSequence(routeSequenceId);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-grow-1 d-flex flex-column">
|
||||
<Course
|
||||
componentDidUpdate() {
|
||||
const {
|
||||
courseId,
|
||||
sequenceId,
|
||||
courseStatus,
|
||||
sequenceStatus,
|
||||
sequence,
|
||||
firstSequenceId,
|
||||
match: {
|
||||
params: {
|
||||
courseId: routeCourseId,
|
||||
sequenceId: routeSequenceId,
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
// Load data whenever the course or sequence ID changes.
|
||||
this.checkFetchCourse(routeCourseId);
|
||||
this.checkFetchSequence(routeSequenceId);
|
||||
|
||||
// Redirect to the legacy experience for exams.
|
||||
checkExamRedirect(sequenceStatus, sequence);
|
||||
|
||||
// Determine if we need to redirect because our URL is incomplete.
|
||||
checkContentRedirect(courseId, sequenceStatus, sequenceId, sequence, routeUnitId);
|
||||
|
||||
// Determine if we can resume where we left off.
|
||||
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId);
|
||||
|
||||
// Check if we should save our sequence position. Only do this when the route unit ID changes.
|
||||
this.checkSaveSequencePosition(routeUnitId);
|
||||
}
|
||||
|
||||
handleUnitNavigationClick = (nextUnitId) => {
|
||||
const {
|
||||
courseId, sequenceId,
|
||||
match: {
|
||||
params: {
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
|
||||
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
|
||||
}
|
||||
|
||||
handleNextSequenceClick = () => {
|
||||
const {
|
||||
course,
|
||||
courseId,
|
||||
nextSequence,
|
||||
sequence,
|
||||
sequenceId,
|
||||
} = this.props;
|
||||
|
||||
if (nextSequence !== null) {
|
||||
let nextUnitId = null;
|
||||
if (nextSequence.unitIds.length > 0) {
|
||||
[nextUnitId] = nextSequence.unitIds;
|
||||
history.push(`/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
|
||||
} else {
|
||||
// Some sequences have no units. This will show a blank page with prev/next buttons.
|
||||
history.push(`/course/${courseId}/${nextSequence.id}`);
|
||||
}
|
||||
|
||||
const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
|
||||
if (celebrateFirstSection && sequence.sectionId !== nextSequence.sectionId) {
|
||||
handleNextSectionCelebration(sequenceId, nextSequence.id, nextUnitId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlePreviousSequenceClick = () => {
|
||||
const { previousSequence, courseId } = this.props;
|
||||
if (previousSequence !== null) {
|
||||
if (previousSequence.unitIds.length > 0) {
|
||||
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
|
||||
history.push(`/course/${courseId}/${previousSequence.id}/${previousUnitId}`);
|
||||
} else {
|
||||
// Some sequences have no units. This will show a blank page with prev/next buttons.
|
||||
history.push(`/course/${courseId}/${previousSequence.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderDenied() {
|
||||
const {
|
||||
course,
|
||||
courseId,
|
||||
match: {
|
||||
params: {
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
} = this.props;
|
||||
let url = `/redirect/course-home/${courseId}`;
|
||||
switch (course.canLoadCourseware.errorCode) {
|
||||
case 'audit_expired':
|
||||
url = `/redirect/dashboard?access_response_error=${course.canLoadCourseware.additionalContextUserMessage}`;
|
||||
break;
|
||||
case 'course_not_started':
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const startDate = (new Intl.DateTimeFormat(getLocale())).format(new Date(course.start));
|
||||
url = `/redirect/dashboard?notlive=${startDate}`;
|
||||
break;
|
||||
case 'survey_required': // TODO: Redirect to the course survey
|
||||
case 'unfulfilled_milestones':
|
||||
url = '/redirect/dashboard';
|
||||
break;
|
||||
case 'microfrontend_disabled':
|
||||
url = `/redirect/courseware/${courseId}/unit/${routeUnitId}`;
|
||||
break;
|
||||
case 'authentication_required':
|
||||
case 'enrollment_required':
|
||||
default:
|
||||
}
|
||||
return (
|
||||
<Redirect to={url} />
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
courseStatus,
|
||||
courseId,
|
||||
sequenceId,
|
||||
match: {
|
||||
params: {
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
if (courseStatus === 'denied') {
|
||||
return this.renderDenied();
|
||||
}
|
||||
|
||||
return (
|
||||
<TabPage
|
||||
activeTabSlug="courseware"
|
||||
courseId={courseId}
|
||||
sequenceId={sequenceId}
|
||||
unitId={routeUnitId}
|
||||
nextSequenceHandler={nextSequenceHandler}
|
||||
previousSequenceHandler={previousSequenceHandler}
|
||||
unitNavigationHandler={unitNavigationHandler}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
courseStatus={courseStatus}
|
||||
>
|
||||
<Course
|
||||
courseId={courseId}
|
||||
sequenceId={sequenceId}
|
||||
unitId={routeUnitId}
|
||||
nextSequenceHandler={this.handleNextSequenceClick}
|
||||
previousSequenceHandler={this.handlePreviousSequenceClick}
|
||||
unitNavigationHandler={this.handleUnitNavigationClick}
|
||||
/>
|
||||
</TabPage>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sequenceShape = PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
isTimeLimited: PropTypes.bool,
|
||||
lmsWebUrl: PropTypes.string,
|
||||
});
|
||||
|
||||
const courseShape = PropTypes.shape({
|
||||
canLoadCourseware: PropTypes.shape({
|
||||
errorCode: PropTypes.string,
|
||||
additionalContextUserMessage: PropTypes.string,
|
||||
}).isRequired,
|
||||
});
|
||||
|
||||
CoursewareContainer.propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
@@ -188,4 +270,123 @@ CoursewareContainer.propTypes = {
|
||||
unitId: PropTypes.string,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
courseId: PropTypes.string,
|
||||
sequenceId: PropTypes.string,
|
||||
firstSequenceId: PropTypes.string,
|
||||
courseStatus: PropTypes.oneOf(['loaded', 'loading', 'failed', 'denied']).isRequired,
|
||||
sequenceStatus: PropTypes.oneOf(['loaded', 'loading', 'failed']).isRequired,
|
||||
nextSequence: sequenceShape,
|
||||
previousSequence: sequenceShape,
|
||||
course: courseShape,
|
||||
sequence: sequenceShape,
|
||||
saveSequencePosition: PropTypes.func.isRequired,
|
||||
checkBlockCompletion: PropTypes.func.isRequired,
|
||||
fetchCourse: PropTypes.func.isRequired,
|
||||
fetchSequence: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
CoursewareContainer.defaultProps = {
|
||||
courseId: null,
|
||||
sequenceId: null,
|
||||
firstSequenceId: null,
|
||||
nextSequence: null,
|
||||
previousSequence: null,
|
||||
course: null,
|
||||
sequence: null,
|
||||
};
|
||||
|
||||
const currentCourseSelector = createSelector(
|
||||
(state) => state.models.courses || {},
|
||||
(state) => state.courseware.courseId,
|
||||
(coursesById, courseId) => (coursesById[courseId] ? coursesById[courseId] : null),
|
||||
);
|
||||
|
||||
const currentSequenceSelector = createSelector(
|
||||
(state) => state.models.sequences || {},
|
||||
(state) => state.courseware.sequenceId,
|
||||
(sequencesById, sequenceId) => (sequencesById[sequenceId] ? sequencesById[sequenceId] : null),
|
||||
);
|
||||
|
||||
const sequenceIdsSelector = createSelector(
|
||||
(state) => state.courseware.courseStatus,
|
||||
currentCourseSelector,
|
||||
(state) => state.models.sections,
|
||||
(courseStatus, course, sectionsById) => {
|
||||
if (courseStatus !== 'loaded') {
|
||||
return [];
|
||||
}
|
||||
const { sectionIds = [] } = course;
|
||||
return sectionIds.flatMap(sectionId => sectionsById[sectionId].sequenceIds);
|
||||
},
|
||||
);
|
||||
|
||||
const previousSequenceSelector = createSelector(
|
||||
sequenceIdsSelector,
|
||||
(state) => state.models.sequences || {},
|
||||
(state) => state.courseware.sequenceId,
|
||||
(sequenceIds, sequencesById, sequenceId) => {
|
||||
if (!sequenceId || sequenceIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sequenceIndex = sequenceIds.indexOf(sequenceId);
|
||||
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
|
||||
return previousSequenceId !== null ? sequencesById[previousSequenceId] : null;
|
||||
},
|
||||
);
|
||||
|
||||
const nextSequenceSelector = createSelector(
|
||||
sequenceIdsSelector,
|
||||
(state) => state.models.sequences || {},
|
||||
(state) => state.courseware.sequenceId,
|
||||
(sequenceIds, sequencesById, sequenceId) => {
|
||||
if (!sequenceId || sequenceIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sequenceIndex = sequenceIds.indexOf(sequenceId);
|
||||
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
|
||||
return nextSequenceId !== null ? sequencesById[nextSequenceId] : null;
|
||||
},
|
||||
);
|
||||
|
||||
const firstSequenceIdSelector = createSelector(
|
||||
(state) => state.courseware.courseStatus,
|
||||
currentCourseSelector,
|
||||
(state) => state.models.sections || {},
|
||||
(courseStatus, course, sectionsById) => {
|
||||
if (courseStatus !== 'loaded') {
|
||||
return null;
|
||||
}
|
||||
const { sectionIds = [] } = course;
|
||||
|
||||
if (sectionIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sectionsById[sectionIds[0]].sequenceIds[0];
|
||||
},
|
||||
);
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
const {
|
||||
courseId, sequenceId, courseStatus, sequenceStatus,
|
||||
} = state.courseware;
|
||||
|
||||
return {
|
||||
courseId,
|
||||
sequenceId,
|
||||
courseStatus,
|
||||
sequenceStatus,
|
||||
course: currentCourseSelector(state),
|
||||
sequence: currentSequenceSelector(state),
|
||||
previousSequence: previousSequenceSelector(state),
|
||||
nextSequence: nextSequenceSelector(state),
|
||||
firstSequenceId: firstSequenceIdSelector(state),
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
checkBlockCompletion,
|
||||
saveSequencePosition,
|
||||
fetchCourse,
|
||||
fetchSequence,
|
||||
})(CoursewareContainer);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user