Compare commits
118 Commits
djoy/id_re
...
REV1512fly
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8de94d00da | ||
|
|
a604e0be10 | ||
|
|
264f36b89e | ||
|
|
2319a7dfb0 | ||
|
|
6549e2b8a2 | ||
|
|
ddfc88dad6 | ||
|
|
e1e3d4992d | ||
|
|
43b0f2fbf0 | ||
|
|
64ff72faa9 | ||
|
|
cc0af77e2f | ||
|
|
7086bdc9ab | ||
|
|
e627fd6f27 | ||
|
|
2ba9440966 | ||
|
|
cd51206462 | ||
|
|
0cb97db7eb | ||
|
|
421b438569 | ||
|
|
1d40baf4cd | ||
|
|
3c5fb46a4d | ||
|
|
5f06d726f7 | ||
|
|
bcd69f5836 | ||
|
|
654fd4c35c | ||
|
|
b98a87c1f5 | ||
|
|
0d29082793 | ||
|
|
2d56bc5953 | ||
|
|
1bfe3f4436 | ||
|
|
3f9f40800a | ||
|
|
a1c7969477 | ||
|
|
53cf637938 | ||
|
|
329bcba31c | ||
|
|
122cef6053 | ||
|
|
a8a8cf5862 | ||
|
|
74149c2c54 | ||
|
|
15975fdd78 | ||
|
|
99f0a4a208 | ||
|
|
4f9cd060be | ||
|
|
cd1d3dd379 | ||
|
|
b08f3d7b45 | ||
|
|
1531f3e912 | ||
|
|
6f415544be | ||
|
|
4eb52a592d | ||
|
|
b4823b90e7 | ||
|
|
5602c0a3b3 | ||
|
|
e0e53f24f1 | ||
|
|
9d8c687e4d | ||
|
|
aeb6a3ebb4 | ||
|
|
e2aa00b16d | ||
|
|
ac711d5f3d | ||
|
|
9d8b5d21b5 | ||
|
|
15ae6d4981 | ||
|
|
f063495cbb | ||
|
|
c5821faee8 | ||
|
|
f83a6e574c | ||
|
|
03661ccf4b | ||
|
|
2d5af74b1b | ||
|
|
ae8141c1a8 | ||
|
|
e9cf5e58de | ||
|
|
1950fe56bd | ||
|
|
94cacb14e7 | ||
|
|
a8dea78e24 | ||
|
|
8adcfb040a | ||
|
|
4f396737e4 | ||
|
|
36f8dd81cd | ||
|
|
f6aebc7d29 | ||
|
|
8a63aef3f0 | ||
|
|
4be37ceb14 | ||
|
|
d123fe6229 | ||
|
|
2f738fdba4 | ||
|
|
d52aa3246e | ||
|
|
e6e5258e5b | ||
|
|
753925ba99 | ||
|
|
684be8c0cf | ||
|
|
42715d3de2 | ||
|
|
4d21633462 | ||
|
|
f91abd319f | ||
|
|
43be11c636 | ||
|
|
72df79b9b8 | ||
|
|
6f331ea6d5 | ||
|
|
d137d5682d | ||
|
|
ea9f5254b7 | ||
|
|
577a19e35c | ||
|
|
f58d405b3b | ||
|
|
927d424d33 | ||
|
|
25e5d39a72 | ||
|
|
d8ed3d6bf8 | ||
|
|
c5a43524a1 | ||
|
|
adfc2d568b | ||
|
|
4c6797c631 | ||
|
|
e2710f6ed3 | ||
|
|
1b859f4ab6 | ||
|
|
f44ce4c311 | ||
|
|
d8dbbaa7a2 | ||
|
|
ddc85f2fd3 | ||
|
|
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 |
15
.env
15
.env
@@ -3,17 +3,28 @@ ACCESS_TOKEN_COOKIE_NAME=null
|
||||
BASE_URL=null
|
||||
CREDENTIALS_BASE_URL=null
|
||||
CSRF_TOKEN_API_PATH=null
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=null
|
||||
ECOMMERCE_BASE_URL=null
|
||||
INSIGHTS_BASE_URL=
|
||||
INSIGHTS_BASE_URL=null
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME=null
|
||||
LMS_BASE_URL=null
|
||||
LOGIN_URL=null
|
||||
LOGOUT_URL=null
|
||||
LOGO_URL=null
|
||||
LOGO_TRADEMARK_URL=null
|
||||
LOGO_WHITE_URL=null
|
||||
FAVICON_URL=null
|
||||
MARKETING_SITE_BASE_URL=null
|
||||
ORDER_HISTORY_URL=null
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=null
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME=null
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN=null
|
||||
STUDIO_BASE_URL=null
|
||||
SUPPORT_URL=null
|
||||
SUPPORT_URL_CALCULATOR_MATH=null
|
||||
SUPPORT_URL_ID_VERIFICATION=null
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE=null
|
||||
TWITTER_HASHTAG=null
|
||||
TWITTER_URL=null
|
||||
STUDIO_BASE_URL=
|
||||
USER_INFO_COOKIE_NAME=null
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
NODE_ENV='development'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:2000'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
|
||||
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
|
||||
TWITTER_HASHTAG='myedxjourney'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
|
||||
17
.env.test
17
.env.test
@@ -1,19 +1,30 @@
|
||||
NODE_ENV='test'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:2000'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
|
||||
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
|
||||
TWITTER_HASHTAG='myedxjourney'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
|
||||
21
.github/workflows/validate.yml
vendored
Normal file
21
.github/workflows/validate.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: validate
|
||||
on:
|
||||
- push
|
||||
- pull_request
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version:
|
||||
- 12
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
5
.gitignore
vendored
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
|
||||
|
||||
15
.travis.yml
15
.travis.yml
@@ -1,15 +0,0 @@
|
||||
language: node_js
|
||||
node_js: 12
|
||||
before_install:
|
||||
- npm install -g npm@6
|
||||
install:
|
||||
- npm ci
|
||||
script:
|
||||
- make validate-no-uncommitted-package-lock-changes
|
||||
- npm run i18n_extract
|
||||
- npm run lint
|
||||
- npm run test
|
||||
- npm run build
|
||||
- npm run is-es5
|
||||
after_success:
|
||||
- codecov
|
||||
14
Makefile
14
Makefile
@@ -52,3 +52,17 @@ pull_translations:
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
# Checking for package-lock.json changes...
|
||||
git diff --exit-code package-lock.json
|
||||
|
||||
.PHONY: validate
|
||||
validate:
|
||||
make validate-no-uncommitted-package-lock-changes
|
||||
npm run i18n_extract
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run test
|
||||
npm run build
|
||||
npm run is-es5
|
||||
|
||||
.PHONY: validate.ci
|
||||
validate.ci:
|
||||
npm ci
|
||||
make validate
|
||||
|
||||
30
README.rst
30
README.rst
@@ -1,4 +1,4 @@
|
||||
|Build Status| |Coveralls| |npm_version| |npm_downloads| |license|
|
||||
|Coveralls| |npm_version| |npm_downloads| |license|
|
||||
|
||||
frontend-app-learning
|
||||
=========================
|
||||
@@ -10,8 +10,6 @@ Introduction
|
||||
|
||||
React app for edX learning.
|
||||
|
||||
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-learning.svg?branch=master
|
||||
:target: https://travis-ci.org/edx/frontend-app-learning
|
||||
.. |Coveralls| image:: https://img.shields.io/coveralls/edx/frontend-app-learning.svg?branch=master
|
||||
:target: https://coveralls.io/github/edx/frontend-app-learning
|
||||
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-learning.svg
|
||||
@@ -43,3 +41,29 @@ In this project, install requirements and start the development server by runnin
|
||||
npm start # The server will run on port 1995
|
||||
|
||||
Once the dev server is up, visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
|
||||
|
||||
Local module development
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
To develop locally on modules that are installed into this app, you'll need to create a ``module.config.js``
|
||||
file (which is git-ignored) that defines where to find your local modules, for instance::
|
||||
|
||||
module.exports = {
|
||||
/*
|
||||
Modules you want to use from local source code. Adding a module here means that when this app
|
||||
runs its build, it'll resolve the source from peer directories of this app.
|
||||
|
||||
moduleName: the name you use to import code from the module.
|
||||
dir: The relative path to the module's source code.
|
||||
dist: The sub-directory of the source code where it puts its build artifact. Often "dist", though you
|
||||
may want to use "src" if the module installs React as a peer/dev dependency.
|
||||
*/
|
||||
localModules: [
|
||||
{ moduleName: '@edx/paragon/scss', dir: '../paragon', dist: 'scss' },
|
||||
{ moduleName: '@edx/paragon', dir: '../paragon', dist: 'dist' },
|
||||
{ moduleName: '@edx/frontend-enterprise', dir: '../frontend-enterprise', dist: 'src' },
|
||||
{ moduleName: '@edx/frontend-platform', dir: '../frontend-platform', dist: 'dist' },
|
||||
],
|
||||
};
|
||||
|
||||
See https://github.com/edx/frontend-build#local-module-configuration-for-webpack for more details.
|
||||
|
||||
41
docs/decisions/0007-testing.md
Normal file
41
docs/decisions/0007-testing.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Testing
|
||||
|
||||
## Status
|
||||
Draft
|
||||
|
||||
Let's live with this a bit longer before deciding it's a solid approach and marking this Approved.
|
||||
|
||||
## Context
|
||||
We'd like to all be on the same page about how to approach testing, what is
|
||||
worth testing, and how to do it.
|
||||
|
||||
## React Testing Library
|
||||
We'll use react-testing-library and jest as the main testing tools.
|
||||
|
||||
This has some implications about how to test. You can read the React Testing Library's
|
||||
[Guiding Principles](https://testing-library.com/docs/guiding-principles), but the main
|
||||
takeaway is that you should be interacting with React as closely as possible to the way
|
||||
the user will interact with it.
|
||||
|
||||
For example, they discourage using class or element name selectors to find components
|
||||
during a test. Instead, you should find them by user-oriented attributes like labels,
|
||||
text, or roles. As a last resort, by a `data-testid` tag.
|
||||
|
||||
## What to Test
|
||||
We have not found exhaustive unit testing of frontend code to be worth the trouble.
|
||||
Rather, let's focus on testing non-obvious behavior.
|
||||
|
||||
In essence: `test behavior that wouldn't present itself to a developer playing around`.
|
||||
|
||||
Practically speaking, this means error states, interactive components, corner cases,
|
||||
or anything that wouldn't come up in a demo course. Something a developer wouldn't
|
||||
notice in the normal course of working in devstack.
|
||||
|
||||
## Snapshots
|
||||
In practice, we've found snapshots of component trees to be too brittle to be worth it,
|
||||
as refactors occur or external libraries change.
|
||||
|
||||
They can still be useful for data (like redux tests) or tiny isolated components.
|
||||
|
||||
But please avoid for any "interesting" component. Prefer inspecting the explicit behavior
|
||||
under test, rather than just snapshotting the entire component tree.
|
||||
8080
package-lock.json
generated
8080
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -34,15 +34,16 @@
|
||||
"url": "https://github.com/edx/frontend-app-learning/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/frontend-component-footer": "10.0.11",
|
||||
"@edx/frontend-component-header": "2.0.5",
|
||||
"@edx/frontend-platform": "1.5.2",
|
||||
"@edx/paragon": "^9.1.1",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.30",
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "^10.1.0",
|
||||
"@edx/frontend-enterprise": "4.2.3",
|
||||
"@edx/frontend-platform": "1.8.0",
|
||||
"@edx/paragon": "^12.3.1",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.32",
|
||||
"@fortawesome/free-brands-svg-icons": "5.13.1",
|
||||
"@fortawesome/free-regular-svg-icons": "5.13.1",
|
||||
"@fortawesome/free-solid-svg-icons": "5.13.1",
|
||||
"@fortawesome/react-fontawesome": "0.1.11",
|
||||
"@fortawesome/react-fontawesome": "0.1.13",
|
||||
"@reduxjs/toolkit": "1.3.6",
|
||||
"classnames": "2.2.6",
|
||||
"core-js": "3.6.5",
|
||||
@@ -51,26 +52,28 @@
|
||||
"react-break": "1.3.2",
|
||||
"react-dom": "16.13.1",
|
||||
"react-helmet": "6.0.0",
|
||||
"react-redux": "7.2.1",
|
||||
"react-redux": "7.2.2",
|
||||
"react-router": "5.2.0",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-share": "4.2.1",
|
||||
"redux": "4.0.5",
|
||||
"regenerator-runtime": "0.13.7",
|
||||
"reselect": "4.0.0"
|
||||
"reselect": "4.0.0",
|
||||
"truncate-html": "1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "5.0.6",
|
||||
"@edx/frontend-build": "5.5.1",
|
||||
"@testing-library/dom": "7.16.3",
|
||||
"@testing-library/jest-dom": "5.10.1",
|
||||
"@testing-library/react": "10.3.0",
|
||||
"@testing-library/user-event": "12.0.17",
|
||||
"axios-mock-adapter": "1.18.2",
|
||||
"codecov": "3.7.2",
|
||||
"es-check": "5.1.0",
|
||||
"es-check": "5.1.4",
|
||||
"glob": "7.1.6",
|
||||
"husky": "3.1.0",
|
||||
"jest": "24.9.0",
|
||||
"jest-chain": "1.1.5",
|
||||
"reactifex": "1.1.1",
|
||||
"rosie": "2.0.1"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<title>Course | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<title>Course | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
|
||||
<% if (htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID) { %>
|
||||
<script src="https://www.edx.org/optimizelyjs/<%= htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID %>.js"></script>
|
||||
<% } %>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -7,11 +7,9 @@ function useAccessExpirationAlert(courseExpiredMessage, topic) {
|
||||
const rawHtml = courseExpiredMessage || null;
|
||||
const isVisible = !!rawHtml; // If it exists, show it.
|
||||
|
||||
const payload = useMemo(() => ({ rawHtml }), [rawHtml]);
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientAccessExpirationAlert',
|
||||
payload,
|
||||
payload: useMemo(() => ({ rawHtml }), [rawHtml]),
|
||||
topic,
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ function EnrollmentAlert({ intl, payload }) {
|
||||
}
|
||||
|
||||
const button = canEnroll && (
|
||||
<Button disabled={loading} className="btn-link p-0 border-0 align-top" onClick={enrollClickHandler}>
|
||||
<Button disabled={loading} variant="link" className="p-0 border-0 align-top" onClick={enrollClickHandler}>
|
||||
{intl.formatMessage(messages.enroll)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React, {
|
||||
useContext, useState, useCallback,
|
||||
useContext, useState, useCallback, useMemo,
|
||||
} from 'react';
|
||||
|
||||
import { UserMessagesContext, ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
@@ -14,15 +14,16 @@ 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: {
|
||||
canEnroll: outline.enrollAlert.canEnroll,
|
||||
courseId,
|
||||
extraText: outline.enrollAlert.extraText,
|
||||
isStaff: course.isStaff,
|
||||
},
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline',
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
|
||||
const OfferAlert = React.lazy(() => import('./OfferAlert'));
|
||||
@@ -10,7 +10,7 @@ export function useOfferAlert(offerHtml, topic) {
|
||||
useAlert(isVisible, {
|
||||
code: 'clientOfferAlert',
|
||||
topic,
|
||||
payload: { rawHtml },
|
||||
payload: useMemo(() => ({ rawHtml }), [rawHtml]),
|
||||
});
|
||||
|
||||
return { clientOfferAlert: OfferAlert };
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
@@ -14,7 +14,7 @@ function CourseTabsNavigation({
|
||||
<div className="container-fluid">
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
aria-label={intl.formatMessage(messages['learn.navigation.course.tabs.label'])}
|
||||
aria-label={intl.formatMessage(messages.courseMaterial)}
|
||||
>
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<a
|
||||
|
||||
34
src/course-header/CourseTabsNavigation.test.jsx
Normal file
34
src/course-header/CourseTabsNavigation.test.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { initializeMockApp, render, screen } from '../setupTest';
|
||||
import { CourseTabsNavigation } from './index';
|
||||
|
||||
describe('Course Tabs Navigation', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
it('renders without tabs', () => {
|
||||
render(<CourseTabsNavigation tabs={[]} />);
|
||||
expect(screen.getByRole('button', { name: 'More...' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with tabs', () => {
|
||||
const tabs = [
|
||||
{ url: 'http://test-url1', title: 'Item 1', slug: 'test1' },
|
||||
{ url: 'http://test-url2', title: 'Item 2', slug: 'test2' },
|
||||
];
|
||||
const mockData = {
|
||||
tabs,
|
||||
activeTabSlug: tabs[0].slug,
|
||||
};
|
||||
render(<CourseTabsNavigation {...mockData} />);
|
||||
|
||||
expect(screen.getByRole('link', { name: tabs[0].title }))
|
||||
.toHaveAttribute('href', tabs[0].url)
|
||||
.toHaveClass('active');
|
||||
|
||||
expect(screen.getByRole('link', { name: tabs[1].title }))
|
||||
.toHaveAttribute('href', tabs[1].url)
|
||||
.not.toHaveClass('active');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
import { useEnterpriseConfig } from '@edx/frontend-enterprise';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import logo from './assets/logo.svg';
|
||||
import messages from './messages';
|
||||
|
||||
function LinkedLogo({
|
||||
href,
|
||||
@@ -28,38 +30,87 @@ LinkedLogo.propTypes = {
|
||||
alt: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default function Header({
|
||||
courseOrg, courseNumber, courseTitle,
|
||||
function Header({
|
||||
courseOrg, courseNumber, courseTitle, intl,
|
||||
}) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
const { enterpriseLearnerPortalLink, enterpriseCustomerBrandingConfig } = useEnterpriseConfig(
|
||||
authenticatedUser,
|
||||
getConfig().ENTERPRISE_LEARNER_PORTAL_HOSTNAME,
|
||||
getConfig().LMS_BASE_URL,
|
||||
);
|
||||
|
||||
let dashboardMenuItem = (
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
|
||||
{intl.formatMessage(messages.dashboard)}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
if (enterpriseLearnerPortalLink && Object.keys(enterpriseLearnerPortalLink).length > 0) {
|
||||
dashboardMenuItem = (
|
||||
<Dropdown.Item
|
||||
href={enterpriseLearnerPortalLink.href}
|
||||
>
|
||||
{enterpriseLearnerPortalLink.content}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
}
|
||||
|
||||
let headerLogo = (
|
||||
<LinkedLogo
|
||||
className="logo"
|
||||
href={`${getConfig().LMS_BASE_URL}/dashboard`}
|
||||
src={getConfig().LOGO_URL}
|
||||
alt={getConfig().SITE_NAME}
|
||||
/>
|
||||
);
|
||||
if (enterpriseCustomerBrandingConfig && Object.keys(enterpriseCustomerBrandingConfig).length > 0) {
|
||||
headerLogo = (
|
||||
<LinkedLogo
|
||||
className="logo"
|
||||
href={enterpriseCustomerBrandingConfig.logoDestination}
|
||||
src={enterpriseCustomerBrandingConfig.logo}
|
||||
alt={enterpriseCustomerBrandingConfig.logoAltText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="course-header">
|
||||
<div className="container-fluid py-2 d-flex align-items-center ">
|
||||
<LinkedLogo
|
||||
className="logo"
|
||||
href={`${getConfig().LMS_BASE_URL}/dashboard`}
|
||||
src={logo}
|
||||
alt={getConfig().SITE_NAME}
|
||||
/>
|
||||
<div className="container-fluid py-2 d-flex align-items-center">
|
||||
{headerLogo}
|
||||
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
|
||||
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
|
||||
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
|
||||
</div>
|
||||
|
||||
<a className="text-gray-700 mr-3" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
|
||||
<Dropdown className="user-dropdown">
|
||||
<Dropdown.Button>
|
||||
<Dropdown.Toggle variant="outline-primary">
|
||||
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
|
||||
<span className="d-none d-md-inline">
|
||||
<span data-hj-suppress 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>
|
||||
<Dropdown.Item href={getConfig().LOGOUT_URL}>Sign Out</Dropdown.Item>
|
||||
{dashboardMenuItem}
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${authenticatedUser.username}`}>
|
||||
{intl.formatMessage(messages.profile)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>
|
||||
{intl.formatMessage(messages.account)}
|
||||
</Dropdown.Item>
|
||||
{!enterpriseLearnerPortalLink && (
|
||||
// Users should only see Order History if they do not have an available
|
||||
// learner portal, because an available learner portal currently means
|
||||
// that they access content via Subscriptions, in which context an "order"
|
||||
// is not relevant.
|
||||
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>
|
||||
{intl.formatMessage(messages.orderHistory)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item href={getConfig().LOGOUT_URL}>
|
||||
{intl.formatMessage(messages.signOut)}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
@@ -71,6 +122,7 @@ Header.propTypes = {
|
||||
courseOrg: PropTypes.string,
|
||||
courseNumber: PropTypes.string,
|
||||
courseTitle: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
Header.defaultProps = {
|
||||
@@ -78,3 +130,5 @@ Header.defaultProps = {
|
||||
courseNumber: null,
|
||||
courseTitle: null,
|
||||
};
|
||||
|
||||
export default injectIntl(Header);
|
||||
|
||||
29
src/course-header/Header.test.jsx
Normal file
29
src/course-header/Header.test.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
authenticatedUser, initializeMockApp, render, screen,
|
||||
} from '../setupTest';
|
||||
import { Header } from './index';
|
||||
|
||||
describe('Header', () => {
|
||||
beforeAll(async () => {
|
||||
// We need to mock AuthService to implicitly use `getAuthenticatedUser` within `AppContext.Provider`.
|
||||
await initializeMockApp();
|
||||
});
|
||||
|
||||
it('displays user button', () => {
|
||||
render(<Header />);
|
||||
expect(screen.getByRole('button')).toHaveTextContent(authenticatedUser.username);
|
||||
});
|
||||
|
||||
it('displays course data', () => {
|
||||
const courseData = {
|
||||
courseOrg: 'course-org',
|
||||
courseNumber: 'course-number',
|
||||
courseTitle: 'course-title',
|
||||
};
|
||||
render(<Header {...courseData} />);
|
||||
|
||||
expect(screen.getByText(`${courseData.courseOrg} ${courseData.courseNumber}`)).toBeInTheDocument();
|
||||
expect(screen.getByText(courseData.courseTitle)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1168px" height="540px" viewBox="0 0 1168 540" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 53.2 (72643) - https://sketchapp.com -->
|
||||
<title>logo</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<polygon id="Path" fill="#209FDA" fill-rule="nonzero" points="1166.81993 85.5 1166.81993 2.84217094e-14 953.759925 2.84217094e-14 953.759925 85.5 1002.17993 85.5 915.859925 191.98 829.459925 85.5 878.099925 85.5 878.099925 2.84217094e-14 718.919925 2.84217094e-14 718.919925 95.72 856.479925 265.26 718.919925 434.96 718.919925 452.02 784.499925 452.02 784.499925 539.64 878.099925 539.64 878.099925 452.02 823.919925 452.02 915.919925 338.52 915.939925 338.52 1008.03993 452.02 953.759925 452.02 953.759925 539.64 1166.81993 539.64 1166.81993 452.02 1126.85993 452.02 975.319925 265.26 1121.01993 85.5"></polygon>
|
||||
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="664.019925 7.10542736e-15 664.019925 85.5 710.619925 85.5 718.919925 95.72 718.919925 7.10542736e-15"></polygon>
|
||||
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="718.919925 452.02 718.919925 434.96 705.079925 452.02 664.019925 452.02 664.019925 539.64 784.499925 539.64 784.499925 452.02"></polygon>
|
||||
<path d="M321.999925,411.86 L397.659925,411.86 C388.805702,433.829527 376.258024,454.122269 360.559925,471.86 C344.364089,454.216816 331.320914,433.921419 321.999925,411.86" id="Path" fill="#78212E" fill-rule="nonzero"></path>
|
||||
<path d="M360.559925,189.28 C338.58337,213.190393 322.501981,241.908137 313.599925,273.14 C317.134915,280.039338 320.007771,287.25831 322.179925,294.7 L397.059925,294.7 C399.306706,287.354671 402.25356,280.242036 405.859925,273.46 C397.464721,242.277678 381.959326,213.464341 360.559925,189.28 Z M322.179925,294.7 C328.784599,317.438017 328.978396,341.558795 322.739925,364.4 L396.399925,364.4 C389.855554,341.597488 390.06397,317.386469 396.999925,294.7 L322.179925,294.7 Z M322.179925,294.7 L308.679925,294.7 C304.690779,317.752715 304.575868,341.309464 308.339925,364.4 L322.739925,364.4 C328.978396,341.558795 328.784599,317.438017 322.179925,294.7 L322.179925,294.7 Z" id="Shape" fill="#78212E" fill-rule="nonzero"></path>
|
||||
<path d="M710.619925,85.5 L664.019925,85.5 L664.019925,0.02 L576.019925,0.02 L576.019925,85.5 L632.859925,85.5 L632.859925,159.2 C598.417874,134.487772 557.04992,121.286425 514.659925,121.48 C456.044663,121.405246 400.107354,146.01621 360.559925,189.28 C381.937732,213.470272 397.422343,242.283149 405.799925,273.46 C426.944121,233.500977 468.451514,208.51034 513.659925,208.52 C581.059925,208.52 632.879925,263.16 632.879925,330.52 L632.879925,331.2 C632.539925,398.28 580.879925,452.56 513.659925,452.56 C468.477451,452.593197 426.976426,427.652566 405.799925,387.74 L405.799925,387.74 C401.869213,380.340239 398.718926,372.551658 396.399925,364.5 L308.399925,364.5 C309.686934,372.450225 311.443338,380.317312 313.659925,388.06 C315.970162,396.190434 318.775397,404.171995 322.059925,411.96 L397.659925,411.96 C388.805702,433.929527 376.258024,454.222269 360.559925,471.96 C400.107354,515.22379 456.044663,539.834754 514.659925,539.76 C571.465111,540.091874 625.745998,516.316729 664.019925,474.34 L664.019925,452.04 L705.059925,452.04 L718.899925,434.96 L718.899925,95.74 L710.619925,85.5 Z M632.879925,501.9 L632.879925,539.74 L664.019925,539.74 L664.019925,474.18 C654.623775,484.469293 644.18821,493.758755 632.879925,501.9 L632.879925,501.9 Z M313.599925,273.14 C311.569597,280.231983 309.927163,287.429316 308.679925,294.7 L322.179925,294.7 C320.007771,287.25831 317.134915,280.039338 313.599925,273.14 L313.599925,273.14 Z" id="Shape" fill="#8A8C8F" fill-rule="nonzero"></path>
|
||||
<path d="M410.399925,294.7 C409.199925,287.5 407.659925,280.4 405.799925,273.46 C402.19356,280.242036 399.246706,287.354671 396.999925,294.7 C390.06397,317.386469 389.855554,341.597488 396.399925,364.4 L410.719925,364.4 C414.264276,341.293291 414.156293,317.77319 410.399925,294.7 L410.399925,294.7 Z M209.059925,121.48 C107.422724,121.487508 20.5081632,194.571683 3.05992537,294.7 L91.3999254,294.7 C107.135726,243.467257 154.465065,208.503753 208.059925,208.52 C252.638644,208.335148 293.496156,233.351373 313.599925,273.14 C322.501981,241.908137 338.58337,213.190393 360.559925,189.28 C322.206855,145.880863 266.976617,121.163964 209.059925,121.48 L209.059925,121.48 Z M297.479925,411.86 C275.077969,437.877726 242.392659,452.761934 208.059925,452.58 C153.691226,452.598435 105.87164,416.63791 90.7999254,364.4 L308.339925,364.4 C304.575868,341.309464 304.690779,317.752715 308.679925,294.7 L3.05992537,294.7 C-0.902504563,317.755068 -1.01739385,341.307372 2.71992537,364.4 L2.71992537,364.4 C19.3292424,465.441984 106.661918,539.594765 209.059925,539.6 C266.986094,539.900862 322.217868,515.161403 360.559925,471.74 C344.364089,454.096816 331.320914,433.801419 321.999925,411.74 L297.479925,411.86 Z" id="Shape" fill="#B72768" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB |
@@ -1,11 +1,41 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learn.navigation.course.tabs.label': {
|
||||
courseMaterial: {
|
||||
id: 'learn.navigation.course.tabs.label',
|
||||
defaultMessage: 'Course Material',
|
||||
description: 'The accessible label for course tabs navigation',
|
||||
},
|
||||
dashboard: {
|
||||
id: 'header.menu.dashboard.label',
|
||||
defaultMessage: 'Dashboard',
|
||||
description: 'The text for the user menu Dashboard navigation link.',
|
||||
},
|
||||
help: {
|
||||
id: 'header.help.label',
|
||||
defaultMessage: 'Help',
|
||||
description: 'The text for the link to the Help Center',
|
||||
},
|
||||
profile: {
|
||||
id: 'header.menu.profile.label',
|
||||
defaultMessage: 'Profile',
|
||||
description: 'The text for the user menu Profile navigation link.',
|
||||
},
|
||||
account: {
|
||||
id: 'header.menu.account.label',
|
||||
defaultMessage: 'Account',
|
||||
description: 'The text for the user menu Account navigation link.',
|
||||
},
|
||||
orderHistory: {
|
||||
id: 'header.menu.orderHistory.label',
|
||||
defaultMessage: 'Order History',
|
||||
description: 'The text for the user menu Order History navigation link.',
|
||||
},
|
||||
signOut: {
|
||||
id: 'header.menu.signOut.label',
|
||||
defaultMessage: 'Sign Out',
|
||||
description: 'The label for the user menu Sign Out action.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
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}`;
|
||||
},
|
||||
);
|
||||
92
src/course-home/data/__factories__/courseBlocks.factory.js
Normal file
92
src/course-home/data/__factories__/courseBlocks.factory.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
import './block.factory';
|
||||
|
||||
// Generates an Array of block IDs, either from a single block or an array of blocks.
|
||||
const getIds = (attr) => {
|
||||
const blocks = Array.isArray(attr) ? attr : [attr];
|
||||
return blocks.map(block => block.id);
|
||||
};
|
||||
|
||||
// Generates an Object in { [block.id]: block } format, either from a single block or an array of blocks.
|
||||
const getBlocks = (attr) => {
|
||||
const blocks = Array.isArray(attr) ? attr : [attr];
|
||||
// eslint-disable-next-line no-return-assign,no-sequences
|
||||
return blocks.reduce((acc, block) => (acc[block.id] = block, acc), {});
|
||||
};
|
||||
|
||||
Factory.define('courseBlocks')
|
||||
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.option('units', ['courseId'], courseId => ([
|
||||
Factory.build(
|
||||
'block',
|
||||
{ type: 'vertical' },
|
||||
{ courseId },
|
||||
),
|
||||
]))
|
||||
.option('sequence', ['courseId', 'units'], (courseId, child) => Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential', children: getIds(child) },
|
||||
{ courseId },
|
||||
))
|
||||
.option('section', ['courseId', 'sequence'], (courseId, child) => Factory.build(
|
||||
'block',
|
||||
{ type: 'chapter', children: getIds(child) },
|
||||
{ courseId },
|
||||
))
|
||||
.option('course', ['courseId', 'section'], (courseId, child) => Factory.build(
|
||||
'block',
|
||||
{ type: 'course', children: getIds(child) },
|
||||
{ courseId },
|
||||
))
|
||||
.attr(
|
||||
'blocks',
|
||||
['course', 'section', 'sequence', 'units'],
|
||||
(course, section, sequence, units) => ({
|
||||
[course.id]: course,
|
||||
...getBlocks(section),
|
||||
...getBlocks(sequence),
|
||||
...getBlocks(units),
|
||||
}),
|
||||
)
|
||||
.attr('root', ['course'], course => course.id);
|
||||
|
||||
/**
|
||||
* Builds a course with a single chapter, sequence, and unit.
|
||||
*/
|
||||
export default function buildSimpleCourseBlocks(courseId, title, options = {}) {
|
||||
const sequenceBlock = options.sequenceBlock || [Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential' },
|
||||
{ courseId },
|
||||
)];
|
||||
const sectionBlock = options.sectionBlock || Factory.build(
|
||||
'block',
|
||||
{
|
||||
type: 'chapter',
|
||||
display_name: 'Title of Section',
|
||||
complete: options.complete || false,
|
||||
resume_block: options.resumeBlock || false,
|
||||
children: sequenceBlock.map(block => block.id),
|
||||
},
|
||||
{ courseId },
|
||||
);
|
||||
const courseBlock = options.courseBlock || Factory.build(
|
||||
'block',
|
||||
{ type: 'course', display_name: title, children: [sectionBlock.id] },
|
||||
{ courseId },
|
||||
);
|
||||
return {
|
||||
courseBlocks: options.courseBlocks || Factory.build(
|
||||
'courseBlocks',
|
||||
{ courseId },
|
||||
{
|
||||
sequence: sequenceBlock,
|
||||
section: sectionBlock,
|
||||
course: courseBlock,
|
||||
},
|
||||
),
|
||||
sequenceBlock,
|
||||
sectionBlock,
|
||||
courseBlock,
|
||||
};
|
||||
}
|
||||
@@ -2,10 +2,8 @@ import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dep
|
||||
|
||||
Factory.define('courseHomeMetadata')
|
||||
.sequence(
|
||||
'course_id',
|
||||
(courseId) => `course-v1:edX+DemoX+Demo_Course_${courseId}`,
|
||||
'courseId', (courseId) => `course-v1:edX+DemoX+Demo_Course_${courseId}`,
|
||||
)
|
||||
.option('courseTabs', [])
|
||||
.option('host', 'http://localhost:18000')
|
||||
.attrs({
|
||||
is_staff: false,
|
||||
@@ -14,11 +12,69 @@ Factory.define('courseHomeMetadata')
|
||||
org: 'edX',
|
||||
title: 'Demonstration Course',
|
||||
is_self_paced: false,
|
||||
is_enrolled: false,
|
||||
})
|
||||
.attr('tabs', ['courseTabs', 'host'], (courseTabs, host) => courseTabs.map(
|
||||
tab => ({
|
||||
tab_id: tab.slug,
|
||||
title: tab.title,
|
||||
url: `${host}${tab.url}`,
|
||||
}),
|
||||
));
|
||||
.attr(
|
||||
'tabs', ['courseId', 'host'], (courseId, host) => {
|
||||
const tabs = [
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Course',
|
||||
priority: 0,
|
||||
slug: 'courseware',
|
||||
type: 'courseware',
|
||||
},
|
||||
{ courseId, path: 'course/' },
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Discussion',
|
||||
priority: 1,
|
||||
slug: 'discussion',
|
||||
type: 'discussion',
|
||||
},
|
||||
{ courseId, path: 'discussion/forum/' },
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Wiki',
|
||||
priority: 2,
|
||||
slug: 'wiki',
|
||||
type: 'wiki',
|
||||
},
|
||||
{ courseId, path: 'course_wiki' },
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Progress',
|
||||
priority: 3,
|
||||
slug: 'progress',
|
||||
type: 'progress',
|
||||
},
|
||||
{ courseId, path: 'progress' },
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Instructor',
|
||||
priority: 4,
|
||||
slug: 'instructor',
|
||||
type: 'instructor',
|
||||
},
|
||||
{ courseId, path: 'instructor' },
|
||||
),
|
||||
];
|
||||
|
||||
return tabs.map(
|
||||
tab => ({
|
||||
tab_id: tab.slug,
|
||||
title: tab.title,
|
||||
url: `${host}${tab.url}`,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
// Sample data helpful when developing & testing, to see a variety of configurations.
|
||||
// This set of data is not realistic (mix of having access and not), but it
|
||||
// is intended to demonstrate many UI results.
|
||||
Factory.define('datesTabData')
|
||||
.attrs({
|
||||
dates_banner_info: {
|
||||
@@ -9,19 +12,213 @@ Factory.define('datesTabData')
|
||||
},
|
||||
course_date_blocks: [
|
||||
{
|
||||
assigment_type: 'Homework',
|
||||
date: '2013-02-05T05:00:00Z',
|
||||
date: '2020-05-01T17:59:41Z',
|
||||
date_type: 'course-start-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: '',
|
||||
title: 'Course Starts',
|
||||
extraInfo: '',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
complete: true,
|
||||
date: '2020-05-04T02:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'Multi Badges Completed',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2020-05-05T02:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'Multi Badges Past Due',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2020-05-27T02:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Past Due 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2020-05-27T02:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Past Due 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
complete: true,
|
||||
date: '2020-05-28T08:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'One Completed/Due 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2020-05-28T08:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'One Completed/Due 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
complete: true,
|
||||
date: '2020-05-29T08:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Completed 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
complete: true,
|
||||
date: '2020-05-29T08:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Completed 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
date: '2020-06-16T17:59:40.942669Z',
|
||||
date_type: 'verified-upgrade-deadline',
|
||||
description: "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'Upgrade to Verified Certificate',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-17T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: false,
|
||||
link: 'https://example.com/',
|
||||
title: 'One Verified 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-17T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'One Verified 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-17T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'ORA Verified 2',
|
||||
extra_info: "ORA Dates are set by the instructor, and can't be changed",
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-18T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: false,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Verified 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-18T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: false,
|
||||
link: 'https://example.com/',
|
||||
title: 'Both Verified 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-19T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'One Unreleased 1',
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-19T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: 'https://example.com/',
|
||||
title: 'One Unreleased 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-20T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'Both Unreleased 1',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
date: '2030-08-20T05:59:40.942669Z',
|
||||
date_type: 'assignment-due-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
title: 'Both Unreleased 2',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
date: '2030-08-23T00:00:00Z',
|
||||
date_type: 'course-end-date',
|
||||
description: '',
|
||||
learner_has_access: true,
|
||||
link: '',
|
||||
title: 'Course Ends',
|
||||
extra_info: null,
|
||||
},
|
||||
{
|
||||
date: '2030-09-01T00:00:00Z',
|
||||
date_type: 'verification-deadline-date',
|
||||
description: 'You must successfully complete verification before this date to qualify for a Verified Certificate.',
|
||||
learner_has_access: false,
|
||||
link: 'https://example.com/',
|
||||
title: 'Verification Deadline',
|
||||
extra_info: null,
|
||||
},
|
||||
],
|
||||
missed_deadlines: false,
|
||||
missed_gated_content: false,
|
||||
learner_is_full_access: true,
|
||||
user_timezone: null,
|
||||
user_timezone: 'America/New_York',
|
||||
verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
});
|
||||
|
||||
@@ -1,25 +1,55 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
import buildSimpleCourseBlocks from '../../../courseware/data/__factories__/courseBlocks.factory';
|
||||
import buildSimpleCourseBlocks from './courseBlocks.factory';
|
||||
|
||||
Factory.define('outlineTabData')
|
||||
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.option('host', 'http://localhost:18000')
|
||||
.attr('course_expired_html', [], () => '<div>Course expired</div>')
|
||||
.attr('course_tools', ['host', 'courseId'], (host, courseId) => ({
|
||||
.option('dateBlocks', [])
|
||||
.attr('course_tools', ['host', 'courseId'], (host, courseId) => ([{
|
||||
analytics_id: 'edx.bookmarks',
|
||||
title: 'Bookmarks',
|
||||
url: `${host}/courses/${courseId}/bookmarks/`,
|
||||
}))
|
||||
}]))
|
||||
.attr('course_blocks', ['courseId'], courseId => {
|
||||
const { courseBlocks } = buildSimpleCourseBlocks(courseId);
|
||||
return {
|
||||
blocks: courseBlocks.blocks,
|
||||
};
|
||||
})
|
||||
.attr('enroll_alert', {
|
||||
can_enroll: true,
|
||||
extra_text: 'Contact the administrator.',
|
||||
})
|
||||
.attr('handouts_html', [], () => '<ul><li>Handout 1</li></ul>')
|
||||
.attr('offer_html', [], () => '<div>Great offer here</div>');
|
||||
.attr('dates_widget', ['dateBlocks'], (dateBlocks) => ({
|
||||
course_date_blocks: dateBlocks,
|
||||
user_timezone: 'UTC',
|
||||
}))
|
||||
.attr('resume_course', ['host', 'courseId'], (host, courseId) => ({
|
||||
has_visited_course: false,
|
||||
url: `${host}/courses/${courseId}/jump_to/block-v1:edX+Test+Block@12345abcde`,
|
||||
}))
|
||||
.attr('verified_mode', ['host'], (host) => ({
|
||||
access_expiration_date: '2050-01-01T12:00:00',
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: 'ABCD1234',
|
||||
upgrade_url: `${host}/dashboard`,
|
||||
}))
|
||||
.attrs({
|
||||
can_show_upgrade_sock: true,
|
||||
course_expired_html: null,
|
||||
course_goals: {
|
||||
goal_options: [],
|
||||
selected_goal: null,
|
||||
},
|
||||
dates_banner_info: {
|
||||
content_type_gating_enabled: false,
|
||||
missed_gated_content: false,
|
||||
missed_deadlines: false,
|
||||
},
|
||||
enroll_alert: {
|
||||
can_enroll: true,
|
||||
extra_text: 'Contact the administrator.',
|
||||
},
|
||||
handouts_html: '<ul><li>Handout 1</li></ul>',
|
||||
offer_html: null,
|
||||
welcome_message_html: '<p>Welcome to this course!</p>',
|
||||
});
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Data layer integration tests Should initialize store 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"displayResetDatesToast": false,
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"models": Object {},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"displayResetDatesToast": false,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
@@ -35,6 +20,7 @@ Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"isEnrolled": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
@@ -74,15 +60,209 @@ Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"courseDateBlocks": Array [
|
||||
Object {
|
||||
"assigmentType": "Homework",
|
||||
"date": "2013-02-05T05:00:00Z",
|
||||
"date": "2020-05-01T17:59:41Z",
|
||||
"dateType": "course-start-date",
|
||||
"description": "",
|
||||
"extraInfo": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "",
|
||||
"title": "Course Starts",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-04T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"title": "Multi Badges Completed",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-05T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"title": "Multi Badges Past Due",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 2",
|
||||
},
|
||||
Object {
|
||||
"date": "2020-06-16T17:59:40.942669Z",
|
||||
"dateType": "verified-upgrade-deadline",
|
||||
"description": "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Upgrade to Verified Certificate",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": "ORA Dates are set by the instructor, and can't be changed",
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "ORA Verified 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"learnerHasAccess": true,
|
||||
"title": "One Unreleased 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Unreleased 2",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"title": "Both Unreleased 1",
|
||||
},
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"title": "Both Unreleased 2",
|
||||
},
|
||||
Object {
|
||||
"date": "2030-08-23T00:00:00Z",
|
||||
"dateType": "course-end-date",
|
||||
"description": "",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": true,
|
||||
"link": "",
|
||||
"title": "Course Ends",
|
||||
},
|
||||
Object {
|
||||
"date": "2030-09-01T00:00:00Z",
|
||||
"dateType": "verification-deadline-date",
|
||||
"description": "You must successfully complete verification before this date to qualify for a Verified Certificate.",
|
||||
"extraInfo": null,
|
||||
"learnerHasAccess": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Verification Deadline",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": Object {
|
||||
"contentTypeGatingEnabled": false,
|
||||
@@ -93,7 +273,7 @@ Object {
|
||||
"learnerIsFullAccess": true,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
"userTimezone": null,
|
||||
"userTimezone": "America/New_York",
|
||||
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
@@ -106,7 +286,9 @@ Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"displayResetDatesToast": false,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
@@ -119,6 +301,7 @@ Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"isEnrolled": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
@@ -156,62 +339,84 @@ Object {
|
||||
},
|
||||
"outline": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"canShowUpgradeSock": true,
|
||||
"courseBlocks": Object {
|
||||
"courses": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd4": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"sectionIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
],
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd4",
|
||||
},
|
||||
},
|
||||
"sections": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
"sequenceIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
],
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
},
|
||||
},
|
||||
"sequences": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"unitIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"sections": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
|
||||
"complete": false,
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"resumeBlock": false,
|
||||
"sequenceIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
],
|
||||
"title": "Title of Section",
|
||||
},
|
||||
},
|
||||
"units": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
|
||||
"graded": false,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"sequenceId": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"sequences": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
|
||||
"complete": false,
|
||||
"description": null,
|
||||
"due": null,
|
||||
"icon": null,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"showLink": true,
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"courseExpiredHtml": "<div>Course expired</div>",
|
||||
"courseTools": Object {
|
||||
"analyticsId": "edx.bookmarks",
|
||||
"title": "Bookmarks",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/bookmarks/",
|
||||
"courseExpiredHtml": null,
|
||||
"courseGoals": Object {
|
||||
"goalOptions": Array [],
|
||||
"selectedGoal": null,
|
||||
},
|
||||
"courseTools": Array [
|
||||
Object {
|
||||
"analyticsId": "edx.bookmarks",
|
||||
"title": "Bookmarks",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/bookmarks/",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": Object {
|
||||
"contentTypeGatingEnabled": false,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
},
|
||||
"datesWidget": Object {
|
||||
"courseDateBlocks": Array [],
|
||||
"userTimezone": "UTC",
|
||||
},
|
||||
"datesWidget": undefined,
|
||||
"enrollAlert": Object {
|
||||
"canEnroll": true,
|
||||
"extraText": "Contact the administrator.",
|
||||
},
|
||||
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
|
||||
"hasEnded": undefined,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"offerHtml": "<div>Great offer here</div>",
|
||||
"welcomeMessageHtml": undefined,
|
||||
"offerHtml": null,
|
||||
"resumeCourse": Object {
|
||||
"hasVisitedCourse": false,
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
|
||||
},
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": "2050-01-01T12:00:00",
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 149,
|
||||
"sku": "ABCD1234",
|
||||
"upgradeUrl": "http://localhost:18000/dashboard",
|
||||
},
|
||||
"welcomeMessageHtml": "<p>Welcome to this course!</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
// TODO: Pull this normalization function up so we're not reaching into courseware
|
||||
import { normalizeBlocks } from '../../courseware/data/api';
|
||||
import { logInfo } from '@edx/frontend-platform/logging';
|
||||
|
||||
function normalizeCourseHomeCourseMetadata(metadata) {
|
||||
const data = camelCaseObject(metadata);
|
||||
@@ -15,13 +14,87 @@ function normalizeCourseHomeCourseMetadata(metadata) {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
const models = {
|
||||
courses: {},
|
||||
sections: {},
|
||||
sequences: {},
|
||||
};
|
||||
Object.values(blocks).forEach(block => {
|
||||
switch (block.type) {
|
||||
case 'course':
|
||||
models.courses[block.id] = {
|
||||
id: courseId,
|
||||
title: block.display_name,
|
||||
sectionIds: block.children || [],
|
||||
};
|
||||
break;
|
||||
|
||||
case 'chapter':
|
||||
models.sections[block.id] = {
|
||||
complete: block.complete,
|
||||
id: block.id,
|
||||
title: block.display_name,
|
||||
resumeBlock: block.resume_block,
|
||||
sequenceIds: block.children || [],
|
||||
};
|
||||
break;
|
||||
|
||||
case 'sequential':
|
||||
models.sequences[block.id] = {
|
||||
complete: block.complete,
|
||||
description: block.description,
|
||||
due: block.due,
|
||||
icon: block.icon,
|
||||
id: block.id,
|
||||
showLink: !!block.lms_web_url, // we reconstruct the url ourselves as an MFE-internal <Link>
|
||||
title: block.display_name,
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
logInfo(`Unexpected course block type: ${block.type} with ID ${block.id}. Expected block types are course, chapter, and sequential.`);
|
||||
}
|
||||
});
|
||||
|
||||
// Next go through each list and use their child lists to decorate those children with a
|
||||
// reference back to their parent.
|
||||
Object.values(models.courses).forEach(course => {
|
||||
if (Array.isArray(course.sectionIds)) {
|
||||
course.sectionIds.forEach(sectionId => {
|
||||
const section = models.sections[sectionId];
|
||||
section.courseId = course.id;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Object.values(models.sections).forEach(section => {
|
||||
if (Array.isArray(section.sequenceIds)) {
|
||||
section.sequenceIds.forEach(sequenceId => {
|
||||
if (sequenceId in models.sequences) {
|
||||
models.sequences[sequenceId].sectionId = section.id;
|
||||
} else {
|
||||
logInfo(`Section ${section.id} has child block ${sequenceId}, but that block is not in the list of sequences.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function getCourseHomeCourseMetadata(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return normalizeCourseHomeCourseMetadata(data);
|
||||
}
|
||||
|
||||
// For debugging purposes, you might like to see a fully loaded dates tab.
|
||||
// Just uncomment the next few lines and the immediate 'return' in the function below
|
||||
// import { Factory } from 'rosie';
|
||||
// import './__factories__';
|
||||
export async function getDatesTabData(courseId) {
|
||||
// return camelCaseObject(Factory.build('datesTabData'));
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
@@ -68,30 +141,47 @@ export async function getOutlineTabData(courseId) {
|
||||
const {
|
||||
data,
|
||||
} = tabData;
|
||||
const courseBlocks = normalizeBlocks(courseId, data.course_blocks.blocks);
|
||||
const canShowUpgradeSock = data.can_show_upgrade_sock;
|
||||
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
|
||||
const courseGoals = camelCaseObject(data.course_goals);
|
||||
const courseExpiredHtml = data.course_expired_html;
|
||||
const courseTools = camelCaseObject(data.course_tools);
|
||||
const datesBannerInfo = camelCaseObject(data.dates_banner_info);
|
||||
const datesWidget = camelCaseObject(data.dates_widget);
|
||||
const enrollAlert = camelCaseObject(data.enroll_alert);
|
||||
const handoutsHtml = data.handouts_html;
|
||||
const hasEnded = data.has_ended;
|
||||
const offerHtml = data.offer_html;
|
||||
const resumeCourse = camelCaseObject(data.resume_course);
|
||||
const verifiedMode = camelCaseObject(data.verified_mode);
|
||||
const welcomeMessageHtml = data.welcome_message_html;
|
||||
|
||||
return {
|
||||
canShowUpgradeSock,
|
||||
courseBlocks,
|
||||
courseGoals,
|
||||
courseExpiredHtml,
|
||||
courseTools,
|
||||
datesBannerInfo,
|
||||
datesWidget,
|
||||
enrollAlert,
|
||||
handoutsHtml,
|
||||
hasEnded,
|
||||
offerHtml,
|
||||
resumeCourse,
|
||||
verifiedMode,
|
||||
welcomeMessageHtml,
|
||||
};
|
||||
}
|
||||
|
||||
export async function postCourseDeadlines(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`);
|
||||
await getAuthenticatedHttpClient().post(url.href, { course_key: courseId });
|
||||
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) {
|
||||
@@ -103,3 +193,8 @@ 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 });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
fetchOutlineTab,
|
||||
fetchProgressTab,
|
||||
resetDeadlines,
|
||||
saveCourseGoal,
|
||||
} from './thunks';
|
||||
|
||||
export { reducer } from './slice';
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as thunks from './thunks';
|
||||
|
||||
import executeThunk from '../../utils';
|
||||
|
||||
import initializeMockApp from '../../setupTest';
|
||||
import { initializeMockApp } from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
|
||||
const { loggingService } = initializeMockApp();
|
||||
@@ -16,20 +16,9 @@ const { loggingService } = initializeMockApp();
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
describe('Data layer integration tests', () => {
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
const courseHomeMetadata = Factory.build(
|
||||
'courseHomeMetadata', {
|
||||
course_id: courseMetadata.id,
|
||||
},
|
||||
{ courseTabs: courseMetadata.tabs },
|
||||
);
|
||||
|
||||
const courseId = courseMetadata.id;
|
||||
const courseBaseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course`;
|
||||
const courseMetadataBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata`;
|
||||
|
||||
const courseUrl = `${courseBaseUrl}/${courseId}`;
|
||||
const courseMetadataUrl = `${courseMetadataBaseUrl}/${courseId}`;
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata');
|
||||
const { courseId } = courseHomeMetadata;
|
||||
const courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
|
||||
let store;
|
||||
|
||||
@@ -40,15 +29,10 @@ describe('Data layer integration tests', () => {
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it('Should initialize store', () => {
|
||||
expect(store.getState()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('Test fetchDatesTab', () => {
|
||||
const datesBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates`;
|
||||
|
||||
it('Should fail to fetch if error occurs', async () => {
|
||||
axiosMock.onGet(courseUrl).networkError();
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).networkError();
|
||||
|
||||
@@ -63,7 +47,6 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const datesUrl = `${datesBaseUrl}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
|
||||
@@ -79,7 +62,6 @@ describe('Data layer integration tests', () => {
|
||||
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline`;
|
||||
|
||||
it('Should result in fetch failure if error occurs', async () => {
|
||||
axiosMock.onGet(courseUrl).networkError();
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
|
||||
|
||||
@@ -94,7 +76,6 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
||||
|
||||
@@ -106,10 +87,22 @@ describe('Data layer integration tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test saveCourseGoal', () => {
|
||||
it('Should save course goal', async () => {
|
||||
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`;
|
||||
axiosMock.onPost(goalUrl).reply(200, {});
|
||||
|
||||
await thunks.saveCourseGoal(courseId, 'unsure');
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(goalUrl);
|
||||
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}","goal_key":"unsure"}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test resetDeadlines', () => {
|
||||
it('Should reset course deadlines', async () => {
|
||||
const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
|
||||
axiosMock.onPost(resetUrl).reply(201);
|
||||
axiosMock.onPost(resetUrl).reply(201, {});
|
||||
|
||||
const getTabDataMock = jest.fn(() => ({
|
||||
type: 'MOCK_ACTION',
|
||||
|
||||
@@ -10,7 +10,9 @@ const slice = createSlice({
|
||||
initialState: {
|
||||
courseStatus: 'loading',
|
||||
courseId: null,
|
||||
displayResetDatesToast: false,
|
||||
toastBodyText: null,
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
},
|
||||
reducers: {
|
||||
fetchTabRequest: (state, { payload }) => {
|
||||
@@ -25,8 +27,15 @@ const slice = createSlice({
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = FAILED;
|
||||
},
|
||||
toggleResetDatesToast: (state, { payload }) => {
|
||||
state.displayResetDatesToast = payload.displayResetDatesToast;
|
||||
setCallToActionToast: (state, { payload }) => {
|
||||
const {
|
||||
header,
|
||||
link,
|
||||
linkText,
|
||||
} = payload;
|
||||
state.toastBodyLink = link;
|
||||
state.toastBodyText = linkText;
|
||||
state.toastHeader = header;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -35,7 +44,7 @@ export const {
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
fetchTabFailure,
|
||||
toggleResetDatesToast,
|
||||
setCallToActionToast,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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';
|
||||
@@ -17,9 +20,13 @@ import {
|
||||
fetchTabFailure,
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
toggleResetDatesToast,
|
||||
setCallToActionToast,
|
||||
} from './slice';
|
||||
|
||||
const eventTypes = {
|
||||
POST_EVENT: 'post_event',
|
||||
};
|
||||
|
||||
export function fetchTab(courseId, tab, getTabData) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchTabRequest({ courseId }));
|
||||
@@ -75,15 +82,6 @@ export function fetchOutlineTab(courseId) {
|
||||
return fetchTab(courseId, 'outline', getOutlineTabData);
|
||||
}
|
||||
|
||||
export function resetDeadlines(courseId, getTabData) {
|
||||
return async (dispatch) => {
|
||||
postCourseDeadlines(courseId).then(() => {
|
||||
dispatch(getTabData(courseId));
|
||||
dispatch(toggleResetDatesToast({ displayResetDatesToast: true }));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function dismissWelcomeMessage(courseId) {
|
||||
return async () => postDismissWelcomeMessage(courseId);
|
||||
}
|
||||
@@ -91,3 +89,40 @@ export function dismissWelcomeMessage(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 }));
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
@@ -14,17 +15,17 @@ function DatesBanner(props) {
|
||||
return (
|
||||
<div className="banner rounded my-4 p-4 container-fluid border border-primary-200 bg-info-100">
|
||||
<div className="row w-100 m-0 justify-content-start justify-content-sm-between">
|
||||
<div className={name === 'datesTabInfoBanner' ? 'col-12' : 'col-12 col-md-9'}>
|
||||
<div className={name === 'datesTabInfoBanner' ? 'col-12' : 'col-12 col-lg-9'}>
|
||||
<strong>
|
||||
{intl.formatMessage(messages[`datesBanner.${name}.header`])}
|
||||
</strong>
|
||||
{intl.formatMessage(messages[`datesBanner.${name}.body`])}
|
||||
</div>
|
||||
{bannerClickHandler && (
|
||||
<div className="col-auto col-md-3 p-md-0 d-inline-flex align-items-center justify-content-start justify-content-md-center">
|
||||
<button type="button" className="btn rounded align-self-center border border-primary bg-white mt-3 mt-md-0 font-weight-bold" onClick={bannerClickHandler}>
|
||||
<div className="col-auto col-lg-3 p-lg-0 d-inline-flex align-items-center justify-content-start justify-content-lg-center">
|
||||
<Button variant="outline-primary" className="align-self-center mt-3 mt-lg-0" onClick={bannerClickHandler}>
|
||||
{intl.formatMessage(messages[`datesBanner.${name}.button`])}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,23 +5,19 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
import DatesBanner from './DatesBanner';
|
||||
import { fetchDatesTab, resetDeadlines } from '../data/thunks';
|
||||
|
||||
function DatesBannerContainer(props) {
|
||||
const {
|
||||
model,
|
||||
} = props;
|
||||
import { resetDeadlines } from '../data';
|
||||
|
||||
function DatesBannerContainer({
|
||||
courseDateBlocks,
|
||||
datesBannerInfo,
|
||||
hasEnded,
|
||||
model,
|
||||
tabFetch,
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
courseDateBlocks,
|
||||
datesBannerInfo,
|
||||
hasEnded,
|
||||
} = useModel(model, courseId);
|
||||
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
missedDeadlines,
|
||||
@@ -45,18 +41,20 @@ function DatesBannerContainer(props) {
|
||||
},
|
||||
{
|
||||
name: 'upgradeToCompleteGradedBanner',
|
||||
shouldDisplay: upgradeToCompleteGraded,
|
||||
clickHandler: () => window.location.replace(verifiedUpgradeLink),
|
||||
// verifiedUpgradeLink can be null if we've passed the upgrade deadline
|
||||
shouldDisplay: upgradeToCompleteGraded && verifiedUpgradeLink,
|
||||
clickHandler: () => global.location.replace(verifiedUpgradeLink),
|
||||
},
|
||||
{
|
||||
name: 'upgradeToResetBanner',
|
||||
shouldDisplay: upgradeToReset,
|
||||
clickHandler: () => window.location.replace(verifiedUpgradeLink),
|
||||
// verifiedUpgradeLink can be null if we've passed the upgrade deadline
|
||||
shouldDisplay: upgradeToReset && verifiedUpgradeLink,
|
||||
clickHandler: () => global.location.replace(verifiedUpgradeLink),
|
||||
},
|
||||
{
|
||||
name: 'resetDatesBanner',
|
||||
shouldDisplay: resetDates,
|
||||
clickHandler: () => dispatch(resetDeadlines(courseId, fetchDatesTab)),
|
||||
clickHandler: () => dispatch(resetDeadlines(courseId, tabFetch)),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -74,7 +72,20 @@ function DatesBannerContainer(props) {
|
||||
}
|
||||
|
||||
DatesBannerContainer.propTypes = {
|
||||
courseDateBlocks: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
datesBannerInfo: PropTypes.shape({
|
||||
contentTypeGatingEnabled: PropTypes.bool.isRequired,
|
||||
missedDeadlines: PropTypes.bool.isRequired,
|
||||
missedGatedContent: PropTypes.bool.isRequired,
|
||||
verifiedUpgradeLink: PropTypes.string,
|
||||
}).isRequired,
|
||||
hasEnded: PropTypes.bool,
|
||||
model: PropTypes.string.isRequired,
|
||||
tabFetch: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
DatesBannerContainer.defaultProps = {
|
||||
hasEnded: false,
|
||||
};
|
||||
|
||||
export default DatesBannerContainer;
|
||||
|
||||
@@ -4,7 +4,10 @@ import classNames from 'classnames';
|
||||
|
||||
export default function Badge({ children, className }) {
|
||||
return (
|
||||
<span className={classNames('dates-badge badge align-text-bottom font-italic ml-2 px-2 py-1', className)}>
|
||||
<span
|
||||
className={classNames('dates-badge badge align-text-bottom font-italic ml-2 px-2 py-1', className)}
|
||||
data-testid="dates-badge"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import Timeline from './Timeline';
|
||||
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
|
||||
|
||||
import { fetchDatesTab } from '../data';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
function DatesTab({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
courseDateBlocks,
|
||||
datesBannerInfo,
|
||||
hasEnded,
|
||||
} = useModel('dates', courseId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div role="heading" aria-level="1" className="h4 my-3">
|
||||
{intl.formatMessage(messages.title)}
|
||||
</div>
|
||||
<DatesBannerContainer model="dates" />
|
||||
<DatesBannerContainer
|
||||
courseDateBlocks={courseDateBlocks}
|
||||
datesBannerInfo={datesBannerInfo}
|
||||
hasEnded={hasEnded}
|
||||
model="dates"
|
||||
tabFetch={fetchDatesTab}
|
||||
/>
|
||||
<Timeline />
|
||||
</>
|
||||
);
|
||||
|
||||
131
src/course-home/dates-tab/DatesTab.test.jsx
Normal file
131
src/course-home/dates-tab/DatesTab.test.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { waitForElementToBeRemoved } from '@testing-library/dom';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import DatesTab from './DatesTab';
|
||||
import { fetchDatesTab } from '../data';
|
||||
import { initializeMockApp } from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
import { TabContainer } from '../../tab-page';
|
||||
import { UserMessagesProvider } from '../../generic/user-messages';
|
||||
|
||||
initializeMockApp();
|
||||
|
||||
describe('DatesTab', () => {
|
||||
let axiosMock;
|
||||
|
||||
const store = initializeStore();
|
||||
const component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Route path="/course/:courseId/dates">
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
</Route>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
const courseMetadata = Factory.build('courseHomeMetadata');
|
||||
const { courseId } = courseMetadata;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`).reply(200, courseMetadata);
|
||||
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
|
||||
});
|
||||
|
||||
// The dates tab is largely repetitive non-interactive static data. Thus it's a little tough to follow
|
||||
// testing-library's advice around testing the way your user uses the site (i.e. can't find form elements by label or
|
||||
// anything). Instead, we find elements by printed date (which is what the user sees) and data-testid. Which is
|
||||
// better than assuming anything about how the surrounding elements are organized by div and span or whatever. And
|
||||
// better than adding non-style class names.
|
||||
// Hence the following getDay query helper.
|
||||
async function getDay(date) {
|
||||
const dateNode = await screen.findByText(date);
|
||||
let parent = dateNode.parentElement;
|
||||
while (parent) {
|
||||
if (parent.dataset && parent.dataset.testid === 'dates-day') {
|
||||
return {
|
||||
day: parent,
|
||||
header: within(parent).getByTestId('dates-header'),
|
||||
items: within(parent).queryAllByTestId('dates-item'),
|
||||
};
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
throw new Error('Did not find day container');
|
||||
}
|
||||
|
||||
describe('when receiving a full set of dates data', () => {
|
||||
beforeEach(() => {
|
||||
const datesTabData = Factory.build('datesTabData');
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||
render(component);
|
||||
});
|
||||
|
||||
it('handles unreleased & complete', async () => {
|
||||
const { header } = await getDay('Sun, May 3, 2020');
|
||||
const badges = within(header).getAllByTestId('dates-badge');
|
||||
expect(badges).toHaveLength(2);
|
||||
expect(badges[0]).toHaveTextContent('Completed');
|
||||
expect(badges[1]).toHaveTextContent('Not Yet Released');
|
||||
});
|
||||
|
||||
it('handles unreleased & past due', async () => {
|
||||
const { header } = await getDay('Mon, May 4, 2020');
|
||||
const badges = within(header).getAllByTestId('dates-badge');
|
||||
expect(badges).toHaveLength(2);
|
||||
expect(badges[0]).toHaveTextContent('Past Due');
|
||||
expect(badges[1]).toHaveTextContent('Not Yet Released');
|
||||
});
|
||||
|
||||
it('handles verified only', async () => {
|
||||
const { day } = await getDay('Sun, Aug 18, 2030');
|
||||
const badge = within(day).getByTestId('dates-badge');
|
||||
expect(badge).toHaveTextContent('Verified Only');
|
||||
});
|
||||
|
||||
it('verified only has no link', async () => {
|
||||
const { day } = await getDay('Sun, Aug 18, 2030');
|
||||
expect(within(day).queryByRole('link')).toBeNull();
|
||||
});
|
||||
|
||||
it('same status items have header badge', async () => {
|
||||
const { day, header } = await getDay('Tue, May 26, 2020');
|
||||
const badge = within(header).getByTestId('dates-badge');
|
||||
expect(badge).toHaveTextContent('Past Due'); // one header badge
|
||||
expect(within(day).getAllByTestId('dates-badge')).toHaveLength(1); // no other badges
|
||||
});
|
||||
|
||||
it('different status items have individual badges', async () => {
|
||||
const { header, items } = await getDay('Thu, May 28, 2020');
|
||||
const headerBadges = within(header).queryAllByTestId('dates-badge');
|
||||
expect(headerBadges).toHaveLength(0); // no header badges
|
||||
expect(items).toHaveLength(2);
|
||||
expect(within(items[0]).getByTestId('dates-badge')).toHaveTextContent('Completed');
|
||||
expect(within(items[1]).getByTestId('dates-badge')).toHaveTextContent('Past Due');
|
||||
});
|
||||
|
||||
it('shows extra info', async () => {
|
||||
const { items } = await getDay('Sat, Aug 17, 2030');
|
||||
expect(items).toHaveLength(3);
|
||||
|
||||
const tipIcon = within(items[2]).getByTestId('dates-extra-info');
|
||||
const tipText = "ORA Dates are set by the instructor, and can't be changed";
|
||||
|
||||
expect(screen.queryByText(tipText)).toBeNull(); // tooltip does not start in DOM
|
||||
userEvent.hover(tipIcon);
|
||||
const tooltip = screen.getByText(tipText); // now it's there
|
||||
userEvent.unhover(tipIcon);
|
||||
waitForElementToBeRemoved(tooltip); // and it's gone again
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,9 @@ import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { FormattedDate, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Tooltip, OverlayTrigger } from '@edx/paragon';
|
||||
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
@@ -24,7 +27,7 @@ function Day({
|
||||
const { color, badges } = getBadgeListAndColor(date, intl, null, items);
|
||||
|
||||
return (
|
||||
<li className="dates-day pb-4">
|
||||
<li className="dates-day pb-4" data-testid="dates-day">
|
||||
{/* Top Line */}
|
||||
{!first && <div className="dates-line-top border-1 border-left border-gray-900 bg-gray-900" />}
|
||||
|
||||
@@ -36,7 +39,7 @@ function Day({
|
||||
|
||||
{/* Content */}
|
||||
<div className="d-inline-block ml-3 pl-2">
|
||||
<div className="mb-1">
|
||||
<div className="mb-1" data-testid="dates-header">
|
||||
<p className="d-inline text-dark-500 font-weight-bold">
|
||||
<FormattedDate
|
||||
value={date}
|
||||
@@ -56,15 +59,24 @@ function Day({
|
||||
const available = item.learnerHasAccess && (item.link || !isLearnerAssignment(item));
|
||||
const textColor = available ? 'text-dark-500' : 'text-dark-200';
|
||||
return (
|
||||
<div key={item.title + item.date} className={textColor}>
|
||||
<div key={item.title + item.date} className={textColor} data-testid="dates-item">
|
||||
<div>
|
||||
<span className="font-weight-bold small mt-1">
|
||||
{item.assignmentType && `${item.assignmentType}: `}{title}
|
||||
</span>
|
||||
{itemBadges}
|
||||
{item.extraInfo && (
|
||||
<OverlayTrigger
|
||||
placement="bottom"
|
||||
overlay={
|
||||
<Tooltip>{item.extraInfo}</Tooltip>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="fa-xs ml-1 text-gray-700" data-testid="dates-extra-info" />
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</div>
|
||||
{item.description && <div className="small mb-2">{item.description}</div>}
|
||||
{item.extraInfo && <div className="small mb-2">{item.extraInfo}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -50,6 +50,7 @@ function getBadgeListAndColor(date, intl, item, items) {
|
||||
shownForDay: assignments.length && assignments.every(isPastDue),
|
||||
shownForItem: x => isLearnerAssignment(x) && isPastDue(x),
|
||||
bg: 'bg-dark-200',
|
||||
className: 'text-white',
|
||||
},
|
||||
{
|
||||
message: messages.dueNext,
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
// Sample data helpful when developing, to see a variety of configurations.
|
||||
// This set of data is not realistic (mix of having access and not), but it
|
||||
// is intended to demonstrate many UI results.
|
||||
// To use, have getDatesTabData in api.js return the result of this call instead:
|
||||
/*
|
||||
import fakeDatesData from '../dates-tab/fakeData';
|
||||
export async function getDatesTabData(courseId, version) {
|
||||
if (tab === 'dates') { return camelCaseObject(fakeDatesData()); }
|
||||
...
|
||||
}
|
||||
*/
|
||||
|
||||
export default function fakeDatesData() {
|
||||
return JSON.parse(`
|
||||
{
|
||||
"course_date_blocks": [
|
||||
{
|
||||
"date": "2020-05-01T17:59:41Z",
|
||||
"date_type": "course-start-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "",
|
||||
"title": "Course Starts",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-04T02:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"title": "Multi Badges Completed",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2020-05-05T02:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"title": "Multi Badges Past Due",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"date": "2020-06-16T17:59:40.942669Z",
|
||||
"date_type": "verified-upgrade-deadline",
|
||||
"description": "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "Upgrade to Verified Certificate",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "ORA Verified 2",
|
||||
"extra_info": "ORA Dates are set by the instructor, and can't be changed"
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"title": "One Unreleased 1"
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "https://example.com/",
|
||||
"title": "One Unreleased 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"title": "Both Unreleased 1",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"assignment_type": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"date_type": "assignment-due-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"title": "Both Unreleased 2",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"date": "2030-08-23T00:00:00Z",
|
||||
"date_type": "course-end-date",
|
||||
"description": "",
|
||||
"learner_has_access": true,
|
||||
"link": "",
|
||||
"title": "Course Ends",
|
||||
"extra_info": null
|
||||
},
|
||||
{
|
||||
"date": "2030-09-01T00:00:00Z",
|
||||
"date_type": "verification-deadline-date",
|
||||
"description": "You must successfully complete verification before this date to qualify for a Verified Certificate.",
|
||||
"learner_has_access": false,
|
||||
"link": "https://example.com/",
|
||||
"title": "Verification Deadline",
|
||||
"extra_info": null
|
||||
}
|
||||
],
|
||||
"display_reset_dates_text": false,
|
||||
"learner_is_verified": false,
|
||||
"user_timezone": "America/New_York",
|
||||
"verified_upgrade_link": "https://example.com/"
|
||||
}
|
||||
`);
|
||||
}
|
||||
@@ -15,8 +15,8 @@ export default function DateSummary({
|
||||
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">
|
||||
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" fixedWidth />
|
||||
<div className="ml-1 font-weight-bold">
|
||||
<FormattedDate
|
||||
value={dateBlock.date}
|
||||
day="numeric"
|
||||
@@ -27,7 +27,7 @@ export default function DateSummary({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row ml-4 px-2">
|
||||
<div className="row ml-4 pl-1 pr-2">
|
||||
<div className="date-summary-text">
|
||||
{linkedTitle
|
||||
&& <div className="font-weight-bold mt-2"><a href={dateBlock.link}>{dateBlock.title}</a></div>}
|
||||
@@ -35,11 +35,10 @@ export default function DateSummary({
|
||||
&& <div className="font-weight-bold mt-2">{dateBlock.title}</div>}
|
||||
</div>
|
||||
{dateBlock.description
|
||||
&& <div className="date-summary-text m-0 mt-1">{dateBlock.description}</div>}
|
||||
&& <div className="date-summary-text mt-1">{dateBlock.description}</div>}
|
||||
{!linkedTitle && dateBlock.link
|
||||
&& <a href={dateBlock.link} className="description-link">{dateBlock.linkText}</a>}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import React from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Button, Toast } from '@edx/paragon';
|
||||
import { AlertList } from '../../generic/user-messages';
|
||||
|
||||
import CourseDates from './widgets/CourseDates';
|
||||
import CourseGoalCard from './widgets/CourseGoalCard';
|
||||
import CourseHandouts from './widgets/CourseHandouts';
|
||||
import CourseSock from '../../generic/course-sock';
|
||||
import CourseTools from './widgets/CourseTools';
|
||||
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
|
||||
import { fetchOutlineTab } from '../data';
|
||||
import genericMessages from '../../generic/messages';
|
||||
import messages from './messages';
|
||||
import Section from './Section';
|
||||
import UpdateGoalSelector from './widgets/UpdateGoalSelector';
|
||||
import UpgradeCard from './widgets/UpgradeCard';
|
||||
import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
|
||||
import useCertificateAvailableAlert from './alerts/certificate-available-alert';
|
||||
import useCourseEndAlert from './alerts/course-end-alert';
|
||||
@@ -36,14 +43,33 @@ function OutlineTab({ intl }) {
|
||||
} = useModel('courses', courseId);
|
||||
|
||||
const {
|
||||
canShowUpgradeSock,
|
||||
courseBlocks: {
|
||||
courses,
|
||||
sections,
|
||||
},
|
||||
courseGoals: {
|
||||
goalOptions,
|
||||
selectedGoal,
|
||||
},
|
||||
courseExpiredHtml,
|
||||
datesBannerInfo,
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
},
|
||||
hasEnded,
|
||||
resumeCourse: {
|
||||
hasVisitedCourse,
|
||||
url: resumeCourseUrl,
|
||||
},
|
||||
offerHtml,
|
||||
verifiedMode,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const [courseGoalToDisplay, setCourseGoalToDisplay] = useState(selectedGoal);
|
||||
const [goalToastHeader, setGoalToastHeader] = useState('');
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
|
||||
// Above the tab alerts (appearing in the order listed here)
|
||||
const logistrationAlert = useLogistrationAlert();
|
||||
const enrollmentAlert = useEnrollmentAlert(courseId);
|
||||
@@ -55,8 +81,9 @@ function OutlineTab({ intl }) {
|
||||
const courseEndAlert = useCourseEndAlert(courseId);
|
||||
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
|
||||
|
||||
const rootCourseId = Object.keys(courses)[0];
|
||||
const { sectionIds } = courses[rootCourseId];
|
||||
const rootCourseId = courses && Object.keys(courses)[0];
|
||||
|
||||
const courseSock = useRef(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -68,13 +95,36 @@ function OutlineTab({ intl }) {
|
||||
...logistrationAlert,
|
||||
}}
|
||||
/>
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<h2>{title}</h2>
|
||||
<Button className="btn-primary" type="button">{intl.formatMessage(messages.resume)}</Button>
|
||||
<Toast
|
||||
closeLabel={intl.formatMessage(genericMessages.close)}
|
||||
onClose={() => setGoalToastHeader('')}
|
||||
show={!!(goalToastHeader)}
|
||||
>
|
||||
{goalToastHeader}
|
||||
</Toast>
|
||||
<div className="row w-100 m-0 mb-3 justify-content-between">
|
||||
<div className="col-12 col-sm-auto p-0">
|
||||
<div role="heading" aria-level="1" className="h4">{title}</div>
|
||||
</div>
|
||||
{resumeCourseUrl && (
|
||||
<div className="col-12 col-sm-auto p-0">
|
||||
<a className="btn btn-primary btn-block" href={resumeCourseUrl}>
|
||||
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col col-8">
|
||||
<WelcomeMessage courseId={courseId} />
|
||||
<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); }}
|
||||
/>
|
||||
)}
|
||||
<AlertList
|
||||
topic="outline-course-alerts"
|
||||
className="mb-3"
|
||||
@@ -86,19 +136,52 @@ function OutlineTab({ intl }) {
|
||||
...offerAlert,
|
||||
}}
|
||||
/>
|
||||
{sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={sectionId}
|
||||
courseId={courseId}
|
||||
title={sections[sectionId].title}
|
||||
sequenceIds={sections[sectionId].sequenceIds}
|
||||
/>
|
||||
))}
|
||||
<DatesBannerContainer
|
||||
courseDateBlocks={courseDateBlocks}
|
||||
datesBannerInfo={datesBannerInfo}
|
||||
hasEnded={hasEnded}
|
||||
model="outline"
|
||||
tabFetch={fetchOutlineTab}
|
||||
/>
|
||||
<WelcomeMessage courseId={courseId} />
|
||||
{rootCourseId && (
|
||||
<>
|
||||
<div className="row w-100 m-0 mb-3 justify-content-end">
|
||||
<div className="col-12 col-sm-auto p-0">
|
||||
<Button variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
|
||||
{expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{courses[rootCourseId].sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={sectionId}
|
||||
courseId={courseId}
|
||||
defaultOpen={sections[sectionId].resumeBlock}
|
||||
expand={expandAll}
|
||||
section={sections[sectionId]}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="col col-4">
|
||||
<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}
|
||||
/>
|
||||
<UpgradeCard
|
||||
courseId={courseId}
|
||||
onLearnMore={canShowUpgradeSock ? () => { courseSock.current.showToUser(); } : null}
|
||||
/>
|
||||
<CourseDates
|
||||
start={start}
|
||||
end={end}
|
||||
@@ -113,6 +196,7 @@ function OutlineTab({ intl }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{canShowUpgradeSock && <CourseSock ref={courseSock} verifiedMode={verifiedMode} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
457
src/course-home/outline-tab/OutlineTab.test.jsx
Normal file
457
src/course-home/outline-tab/OutlineTab.test.jsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { ALERT_TYPES } from '../../generic/user-messages';
|
||||
import buildSimpleCourseBlocks from '../data/__factories__/courseBlocks.factory';
|
||||
import {
|
||||
fireEvent, initializeMockApp, logUnhandledRequests, render, screen, waitFor,
|
||||
} from '../../setupTest';
|
||||
import executeThunk from '../../utils';
|
||||
import * as thunks from '../data/thunks';
|
||||
import initializeStore from '../../store';
|
||||
import OutlineTab from './OutlineTab';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('Outline Tab', () => {
|
||||
let axiosMock;
|
||||
|
||||
const courseId = 'course-v1:edX+Test+run';
|
||||
const courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
|
||||
const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
|
||||
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`;
|
||||
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
|
||||
|
||||
const store = initializeStore();
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata', { courseId });
|
||||
const defaultTabData = Factory.build('outlineTabData');
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', { courseId, ...attributes }, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
function setTabData(attributes, options) {
|
||||
const outlineTabData = Factory.build('outlineTabData', attributes, options);
|
||||
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
||||
}
|
||||
|
||||
async function fetchAndRender() {
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
render(<OutlineTab />, { store });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
// Set defaults for network requests
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onPost(enrollmentUrl).reply(200, {});
|
||||
axiosMock.onPost(goalUrl).reply(200, { header: 'Success' });
|
||||
axiosMock.onGet(outlineUrl).reply(200, defaultTabData);
|
||||
|
||||
logUnhandledRequests(axiosMock);
|
||||
});
|
||||
|
||||
describe('Course Outline', () => {
|
||||
it('displays link to start course', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays link to resume course', async () => {
|
||||
setTabData({
|
||||
resume_course: {
|
||||
has_visited_course: true,
|
||||
url: `${getConfig().LMS_BASE_URL}/courses/${courseId}/jump_to/block-v1:edX+Test+Block@12345abcde`,
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Resume Course' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands section that contains resume block', async () => {
|
||||
const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
const expandedSectionNode = screen.getByRole('button', { name: /Title of Section/ });
|
||||
expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('handles expand/collapse all button click', async () => {
|
||||
await fetchAndRender();
|
||||
// Button renders as "Expand All"
|
||||
const expandButton = screen.getByRole('button', { name: 'Expand All' });
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
|
||||
// Section initially renders collapsed
|
||||
const collapsedSectionNode = screen.getByRole('button', { name: /section/ });
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
// Click to expand section
|
||||
userEvent.click(expandButton);
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Click to collapse section
|
||||
userEvent.click(expandButton);
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('displays correct icon for complete assignment', async () => {
|
||||
const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { complete: true });
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTitle('Completed section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays correct icon for incomplete assignment', async () => {
|
||||
const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { complete: false });
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Welcome Message', () => {
|
||||
it('does not render show more/less button under 100 words', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('over 100 words', () => {
|
||||
beforeEach(async () => {
|
||||
setTabData({
|
||||
welcome_message_html: '<p>'
|
||||
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
|
||||
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
|
||||
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
|
||||
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
|
||||
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
|
||||
+ '</p>',
|
||||
});
|
||||
await fetchAndRender();
|
||||
});
|
||||
|
||||
it('shortens message', async () => {
|
||||
expect(screen.getByTestId('short-welcome-message-iframe')).toBeInTheDocument();
|
||||
const showMoreButton = screen.queryByRole('button', { name: 'Show More' });
|
||||
expect(showMoreButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders show more/less button and handles click', async () => {
|
||||
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
|
||||
let showMoreButton = screen.getByRole('button', { name: 'Show More' });
|
||||
expect(showMoreButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(showMoreButton);
|
||||
let showLessButton = screen.getByRole('button', { name: 'Show Less' });
|
||||
expect(showLessButton).toBeInTheDocument();
|
||||
expect(screen.getByTestId('long-welcome-message-iframe')).toBeInTheDocument();
|
||||
|
||||
userEvent.click(showLessButton);
|
||||
showLessButton = screen.queryByRole('button', { name: 'Show Less' });
|
||||
expect(showLessButton).not.toBeInTheDocument();
|
||||
showMoreButton = screen.getByRole('button', { name: 'Show More' });
|
||||
expect(showMoreButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not display if no update available', async () => {
|
||||
setTabData({ welcome_message_html: null });
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('alert-container-welcome')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Goals', () => {
|
||||
const goalOptions = [
|
||||
['certify', 'Earn a certificate'],
|
||||
['complete', 'Complete the course'],
|
||||
['explore', 'Explore the course'],
|
||||
['unsure', 'Not sure yet'],
|
||||
];
|
||||
|
||||
it('does not render goal widgets if no goals available', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('course-goal-card')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('edit-goal-selector')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('goal is not set', () => {
|
||||
beforeEach(async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
goal_options: goalOptions,
|
||||
selected_goal: null,
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
});
|
||||
|
||||
it('renders goal card', () => {
|
||||
expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('course-goal-card')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Complete the course' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Explore the course' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Not sure yet' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders goal selector on goal selection', async () => {
|
||||
const certifyGoalButton = screen.getByRole('button', { name: 'Earn a certificate' });
|
||||
fireEvent.click(certifyGoalButton);
|
||||
|
||||
const goalSelector = await screen.findByTestId('edit-goal-selector');
|
||||
expect(goalSelector).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('goal is set', () => {
|
||||
beforeEach(async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
goal_options: goalOptions,
|
||||
selected_goal: { text: 'Earn a certificate', key: 'certify' },
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
});
|
||||
|
||||
it('renders edit goal selector', () => {
|
||||
expect(screen.getByLabelText('Goal')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('edit-goal-selector')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates goal on click', async () => {
|
||||
// Open dropdown
|
||||
const dropdownButtonNode = screen.getByRole('button', { name: 'Earn a certificate' });
|
||||
await waitFor(() => {
|
||||
expect(dropdownButtonNode).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(dropdownButtonNode);
|
||||
|
||||
// Select a new goal
|
||||
const unsureButtonNode = screen.getByRole('button', { name: 'Not sure yet' });
|
||||
await waitFor(() => {
|
||||
expect(unsureButtonNode).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(unsureButtonNode);
|
||||
|
||||
// Verify the request was made
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
|
||||
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","goal_key":"unsure"}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Handouts', () => {
|
||||
it('renders title when handouts are available', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('heading', { name: 'Course Handouts' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display title if no handouts available', async () => {
|
||||
setTabData({ handouts_html: null });
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('heading', { name: 'Course Handouts' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alert List', () => {
|
||||
describe('Enrollment Alert', () => {
|
||||
let alertMessage;
|
||||
let staffMessage;
|
||||
|
||||
beforeEach(() => {
|
||||
const extraText = defaultTabData.enroll_alert.extra_text;
|
||||
alertMessage = `You must be enrolled in the course to see course content. ${extraText}`;
|
||||
staffMessage = 'You are viewing this course as staff, and are not enrolled.';
|
||||
});
|
||||
|
||||
it('does not display enrollment alert for enrolled user', async () => {
|
||||
setMetadata({ is_enrolled: true });
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText(alertMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display enrollment button if enrollment is not available', async () => {
|
||||
setTabData({
|
||||
enroll_alert: {
|
||||
can_enroll: false,
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('button', { name: 'Enroll Now' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays enrollment alert for unenrolled user', async () => {
|
||||
await fetchAndRender();
|
||||
|
||||
const alert = await screen.findByText(alertMessage);
|
||||
expect(alert).toHaveAttribute('role', 'alert');
|
||||
const alertContainer = await screen.findByTestId(`alert-container-${ALERT_TYPES.ERROR}`);
|
||||
expect(screen.queryByText(staffMessage)).not.toBeInTheDocument();
|
||||
|
||||
expect(alertContainer.querySelector('svg')).toHaveClass('fa-exclamation-triangle');
|
||||
});
|
||||
|
||||
it('displays different message for unenrolled staff user', async () => {
|
||||
setMetadata({ is_staff: true });
|
||||
await fetchAndRender();
|
||||
|
||||
const alert = await screen.findByText(staffMessage);
|
||||
expect(alert).toHaveAttribute('role', 'alert');
|
||||
expect(screen.queryByText(alertMessage)).not.toBeInTheDocument();
|
||||
const alertContainer = await screen.findByTestId(`alert-container-${ALERT_TYPES.INFO}`);
|
||||
expect(alertContainer.querySelector('svg')).toHaveClass('fa-info-circle');
|
||||
});
|
||||
|
||||
it('handles button click', async () => {
|
||||
const { location } = window;
|
||||
delete window.location;
|
||||
window.location = {
|
||||
reload: jest.fn(),
|
||||
};
|
||||
await fetchAndRender();
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Enroll Now' });
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
|
||||
expect(axiosMock.history.post[0].data)
|
||||
.toEqual(JSON.stringify({ course_details: { course_id: courseId } }));
|
||||
expect(window.location.reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.location = location;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Access Expiration Alert', () => {
|
||||
// Appears if course_expired_html is provided
|
||||
it('appears', async () => {
|
||||
setTabData({ course_expired_html: '<p>Course Will Expire, Uh Oh</p>' });
|
||||
await fetchAndRender();
|
||||
await screen.findByText('Course Will Expire, Uh Oh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Start Alert', () => {
|
||||
// Only appears if enrolled and before start of course
|
||||
it('appears several days out', async () => {
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() + 100);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({}, {
|
||||
dateBlocks: [
|
||||
{
|
||||
date_type: 'course-start-date',
|
||||
date: startDate.toISOString(),
|
||||
title: 'Start',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
const node = await screen.findByText('Course starts', { exact: false });
|
||||
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
|
||||
});
|
||||
|
||||
it('appears today', async () => {
|
||||
const startDate = new Date();
|
||||
startDate.setHours(startDate.getHours() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({}, {
|
||||
dateBlocks: [
|
||||
{
|
||||
date_type: 'course-start-date',
|
||||
date: startDate.toISOString(),
|
||||
title: 'Start',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
const node = await screen.findByText('Course starts', { exact: false });
|
||||
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course End Alert', () => {
|
||||
// Only appears if enrolled and within 14 days before the end of course
|
||||
it('appears several days out', async () => {
|
||||
const endDate = new Date();
|
||||
endDate.setDate(endDate.getDate() + 13);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({}, {
|
||||
dateBlocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: endDate.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
const node = await screen.findByText('This course is ending', { exact: false });
|
||||
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
|
||||
});
|
||||
|
||||
it('appears today', async () => {
|
||||
const endDate = new Date();
|
||||
endDate.setHours(endDate.getHours() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({}, {
|
||||
dateBlocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: endDate.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
const node = await screen.findByText('This course is ending', { exact: false });
|
||||
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date
|
||||
});
|
||||
});
|
||||
|
||||
describe('Certificate Available Alert', () => {
|
||||
// Must satisfy two conditions for alert to appear: enrolled and between course end and cert availability
|
||||
it('appears', async () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({}, {
|
||||
dateBlocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: yesterday.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
{
|
||||
date_type: 'certificate-available-date',
|
||||
date: tomorrow.toISOString(),
|
||||
title: 'Cert Available',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('We are working on generating course certificates.');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,110 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Collapsible } from '@edx/paragon';
|
||||
import { faChevronRight, faChevronDown } from '@fortawesome/free-solid-svg-icons';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Collapsible, IconButton } from '@edx/paragon';
|
||||
import { faCheckCircle as fasCheckCircle, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import SequenceLink from './SequenceLink';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import genericMessages from '../../generic/messages';
|
||||
import messages from './messages';
|
||||
|
||||
export default function Section({ courseId, title, sequenceIds }) {
|
||||
function Section({
|
||||
courseId,
|
||||
defaultOpen,
|
||||
expand,
|
||||
intl,
|
||||
section,
|
||||
}) {
|
||||
const {
|
||||
complete,
|
||||
sequenceIds,
|
||||
title,
|
||||
} = section;
|
||||
const {
|
||||
courseBlocks: {
|
||||
sequences,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
return (
|
||||
<Collapsible.Advanced className="collapsible-card mb-2">
|
||||
<Collapsible.Trigger className="collapsible-trigger d-flex align-items-start">
|
||||
<Collapsible.Visible whenClosed>
|
||||
<div style={{ minWidth: '1rem' }}>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</div>
|
||||
</Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen>
|
||||
<div style={{ minWidth: '1rem' }}>
|
||||
<FontAwesomeIcon icon={faChevronDown} />
|
||||
</div>
|
||||
</Collapsible.Visible>
|
||||
<div className="ml-2 flex-grow-1">{title}</div>
|
||||
</Collapsible.Trigger>
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
<Collapsible.Body className="collapsible-body">
|
||||
{sequenceIds.map((sequenceId) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequenceId}
|
||||
courseId={courseId}
|
||||
title={sequences[sequenceId].title}
|
||||
/>
|
||||
))}
|
||||
</Collapsible.Body>
|
||||
</Collapsible.Advanced>
|
||||
useEffect(() => {
|
||||
setOpen(expand);
|
||||
}, [expand]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(defaultOpen);
|
||||
}, []);
|
||||
|
||||
const sectionTitle = (
|
||||
<div>
|
||||
{complete ? (
|
||||
<FontAwesomeIcon
|
||||
icon={fasCheckCircle}
|
||||
className="float-left mt-1 text-success"
|
||||
aria-hidden="true"
|
||||
title={intl.formatMessage(messages.completedSection)}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={farCheckCircle}
|
||||
className="float-left mt-1 text-gray-200"
|
||||
aria-hidden="true"
|
||||
title={intl.formatMessage(messages.incompleteSection)}
|
||||
/>
|
||||
)}
|
||||
<div className="ml-3 pl-3 font-weight-bold">
|
||||
{title}
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className="mb-2"
|
||||
styling="card-lg"
|
||||
title={sectionTitle}
|
||||
open={open}
|
||||
onToggle={() => { setOpen(!open); }}
|
||||
iconWhenClosed={(
|
||||
<IconButton
|
||||
alt={intl.formatMessage(messages.openSection)}
|
||||
icon={faPlus}
|
||||
onClick={() => { setOpen(true); }}
|
||||
/>
|
||||
)}
|
||||
iconWhenOpen={(
|
||||
<IconButton
|
||||
alt={intl.formatMessage(genericMessages.close)}
|
||||
icon={faMinus}
|
||||
onClick={() => { setOpen(false); }}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{sequenceIds.map((sequenceId, index) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequenceId}
|
||||
courseId={courseId}
|
||||
sequence={sequences[sequenceId]}
|
||||
first={index === 0}
|
||||
/>
|
||||
))}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
Section.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
sequenceIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
defaultOpen: PropTypes.bool.isRequired,
|
||||
expand: PropTypes.bool.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
section: PropTypes.shape().isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Section);
|
||||
|
||||
@@ -1,17 +1,107 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
FormattedMessage,
|
||||
FormattedTime,
|
||||
injectIntl,
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { faCheckCircle as fasCheckCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
function SequenceLink({
|
||||
id,
|
||||
intl,
|
||||
courseId,
|
||||
first,
|
||||
sequence,
|
||||
}) {
|
||||
const {
|
||||
complete,
|
||||
description,
|
||||
due,
|
||||
showLink,
|
||||
title,
|
||||
} = sequence;
|
||||
const {
|
||||
datesWidget: {
|
||||
userTimezone,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const displayTitle = showLink ? <Link to={`/course/${courseId}/${id}`}>{title}</Link> : title;
|
||||
|
||||
export default function SequenceLink({ id, courseId, title }) {
|
||||
return (
|
||||
<div className="ml-4">
|
||||
<Link to={`/course/${courseId}/${id}`}>{title}</Link>
|
||||
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-auto p-0">
|
||||
{complete ? (
|
||||
<FontAwesomeIcon
|
||||
icon={fasCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left text-success mt-1"
|
||||
aria-hidden="true"
|
||||
title={intl.formatMessage(messages.completedAssignment)}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={farCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left text-gray-200 mt-1"
|
||||
aria-hidden="true"
|
||||
title={intl.formatMessage(messages.incompleteAssignment)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-10 p-0 ml-2 pl-1 text-break">{displayTitle}</div>
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)}
|
||||
</span>
|
||||
</div>
|
||||
{due && (
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body">
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due"
|
||||
defaultMessage="{description} due {assignmentDue}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
hour12={false}
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SequenceLink.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
first: PropTypes.bool.isRequired,
|
||||
sequence: PropTypes.shape().isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SequenceLink);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { useAlert } from '../../../../generic/user-messages';
|
||||
@@ -23,14 +23,15 @@ function useCertificateAvailableAlert(courseId) {
|
||||
const endDate = endBlock ? new Date(endBlock.date) : null;
|
||||
const hasEnded = endBlock ? endDate < new Date() : false;
|
||||
const isVisible = isEnrolled && certBlock && hasEnded; // only show if we're between end and cert dates
|
||||
const payload = {
|
||||
certDate: certBlock && certBlock.date,
|
||||
username,
|
||||
userTimezone,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCertificateAvailableAlert',
|
||||
payload: {
|
||||
certDate: certBlock && certBlock.date,
|
||||
username,
|
||||
userTimezone,
|
||||
},
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../../../generic/user-messages';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
@@ -23,15 +23,16 @@ export function useCourseEndAlert(courseId) {
|
||||
const endDate = endBlock ? new Date(endBlock.date) : null;
|
||||
const delta = endBlock ? endDate - new Date() : 0;
|
||||
const isVisible = isEnrolled && endBlock && delta > 0 && delta < WARNING_PERIOD_MS;
|
||||
const payload = {
|
||||
delta,
|
||||
description: endBlock && endBlock.description,
|
||||
endDate: endBlock && endBlock.date,
|
||||
userTimezone,
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCourseEndAlert',
|
||||
payload: {
|
||||
delta,
|
||||
description: endBlock && endBlock.description,
|
||||
endDate: endBlock && endBlock.date,
|
||||
userTimezone,
|
||||
},
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
|
||||
@@ -5,22 +5,101 @@ const messages = defineMessages({
|
||||
id: 'learning.outline.dates.all',
|
||||
defaultMessage: 'View all course dates',
|
||||
},
|
||||
collapseAll: {
|
||||
id: 'learning.outline.collapseAll',
|
||||
defaultMessage: 'Collapse All',
|
||||
description: 'Label for button to close all of the collapsible sections',
|
||||
},
|
||||
completedAssignment: {
|
||||
id: 'learning.outline.completedAssignment',
|
||||
defaultMessage: 'Completed',
|
||||
description: 'Text used to describe the green checkmark icon in front of an assignment title',
|
||||
},
|
||||
completedSection: {
|
||||
id: 'learning.outline.completedSection',
|
||||
defaultMessage: 'Completed section',
|
||||
description: 'Text used to describe the green checkmark icon in front of a section title',
|
||||
},
|
||||
dates: {
|
||||
id: 'learning.outline.dates',
|
||||
defaultMessage: 'Upcoming Dates',
|
||||
},
|
||||
editGoal: {
|
||||
id: 'learning.outline.editGoal',
|
||||
defaultMessage: 'Edit goal',
|
||||
description: 'Edit course goal button',
|
||||
},
|
||||
expandAll: {
|
||||
id: 'learning.outline.expandAll',
|
||||
defaultMessage: 'Expand All',
|
||||
description: 'Label for button to open all of the collapsible sections',
|
||||
},
|
||||
goal: {
|
||||
id: 'learning.outline.goal',
|
||||
defaultMessage: 'Goal',
|
||||
description: 'Label for the selected course goal',
|
||||
},
|
||||
goalUnsure: {
|
||||
id: 'learning.outline.goalUnsure',
|
||||
defaultMessage: 'Not sure yet',
|
||||
},
|
||||
goalWelcome: {
|
||||
id: 'learning.outline.goalWelcome',
|
||||
defaultMessage: 'Welcome to',
|
||||
description: 'This precedes the title of the course',
|
||||
},
|
||||
handouts: {
|
||||
id: 'learning.outline.handouts',
|
||||
defaultMessage: 'Course Handouts',
|
||||
},
|
||||
incompleteAssignment: {
|
||||
id: 'learning.outline.incompleteAssignment',
|
||||
defaultMessage: 'Incomplete',
|
||||
description: 'Text used to describe the gray checkmark icon in front of an assignment title',
|
||||
},
|
||||
incompleteSection: {
|
||||
id: 'learning.outline.incompleteSection',
|
||||
defaultMessage: 'Incomplete section',
|
||||
description: 'Text used to describe the gray checkmark icon in front of a section title',
|
||||
},
|
||||
learnMore: {
|
||||
id: 'learning.outline.learnMore',
|
||||
defaultMessage: 'Learn More',
|
||||
},
|
||||
openSection: {
|
||||
id: 'learning.outline.altText.openSection',
|
||||
defaultMessage: 'Open',
|
||||
description: 'A button to open the given section of the course outline',
|
||||
},
|
||||
resume: {
|
||||
id: 'learning.outline.resume',
|
||||
defaultMessage: 'Resume Course',
|
||||
},
|
||||
setGoal: {
|
||||
id: 'learning.outline.setGoal',
|
||||
defaultMessage: 'To start, set a course goal by selecting the option below that best describes your learning plan.',
|
||||
},
|
||||
start: {
|
||||
id: 'learning.outline.start',
|
||||
defaultMessage: 'Start Course',
|
||||
},
|
||||
tools: {
|
||||
id: 'learning.outline.tools',
|
||||
defaultMessage: 'Course Tools',
|
||||
},
|
||||
upgradeButton: {
|
||||
id: 'learning.outline.upgradeButton',
|
||||
defaultMessage: 'Upgrade ({symbol}{price})',
|
||||
},
|
||||
upgradeTitle: {
|
||||
id: 'learning.outline.upgradeTitle',
|
||||
defaultMessage: 'Pursue a verified certificate',
|
||||
},
|
||||
certAlt: {
|
||||
id: 'learning.outline.certificateAlt',
|
||||
defaultMessage: 'Example Certificate',
|
||||
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
|
||||
},
|
||||
welcomeMessage: {
|
||||
id: 'learning.outline.welcomeMessage',
|
||||
defaultMessage: 'Welcome Message',
|
||||
|
||||
@@ -9,20 +9,24 @@ import { useModel } from '../../../generic/model-store';
|
||||
|
||||
function CourseDates({ courseId, intl }) {
|
||||
const {
|
||||
datesWidget,
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
datesTabLink,
|
||||
userTimezone,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
return (
|
||||
<section className="mb-3">
|
||||
<h4>{intl.formatMessage(messages.dates)}</h4>
|
||||
{datesWidget.courseDateBlocks.map((courseDateBlock) => (
|
||||
<section className="mb-4">
|
||||
<h2 className="h6">{intl.formatMessage(messages.dates)}</h2>
|
||||
{courseDateBlocks.map((courseDateBlock) => (
|
||||
<DateSummary
|
||||
key={courseDateBlock.title + courseDateBlock.date}
|
||||
dateBlock={courseDateBlock}
|
||||
userTimezone={datesWidget.userTimezone}
|
||||
userTimezone={userTimezone}
|
||||
/>
|
||||
))}
|
||||
<a className="font-weight-bold" href={datesWidget.datesTabLink}>
|
||||
<a className="font-weight-bold ml-4 pl-1" href={datesTabLink}>
|
||||
{intl.formatMessage(messages.allDates)}
|
||||
</a>
|
||||
</section>
|
||||
|
||||
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" data-testid="course-goal-card">
|
||||
<Card.Body>
|
||||
<div className="row w-100 m-0 justify-content-between align-items-center">
|
||||
<div className="col col-8 p-0">
|
||||
<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);
|
||||
@@ -17,8 +17,8 @@ function CourseHandouts({ courseId, intl }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-3">
|
||||
<h4>{intl.formatMessage(messages.handouts)}</h4>
|
||||
<section className="mb-4">
|
||||
<h2 className="h6">{intl.formatMessage(messages.handouts)}</h2>
|
||||
<LmsHtmlFragment
|
||||
html={handoutsHtml}
|
||||
title={intl.formatMessage(messages.handouts)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
@@ -14,14 +14,21 @@ import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
function CourseTools({ courseId, intl }) {
|
||||
const { org } = useModel('courses', courseId);
|
||||
const {
|
||||
courseTools,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
if (courseTools.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const logClick = (analyticsId) => {
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
sendTrackEvent('edx.course.tool.accessed', {
|
||||
course_id: courseId,
|
||||
sendTrackingLogEvent('edx.course.tool.accessed', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
course_id: courseId, // should only be courserun_key, but left as-is for historical reasons
|
||||
is_staff: administrator,
|
||||
tool_name: analyticsId,
|
||||
});
|
||||
@@ -47,12 +54,12 @@ function CourseTools({ courseId, intl }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mb-3">
|
||||
<h4>{intl.formatMessage(messages.tools)}</h4>
|
||||
<section className="mb-4">
|
||||
<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' }} />
|
||||
<FontAwesomeIcon icon={renderIcon(courseTool.analyticsId)} className="mr-2" fixedWidth />
|
||||
{courseTool.title}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
85
src/course-home/outline-tab/widgets/UpdateGoalSelector.jsx
Normal file
85
src/course-home/outline-tab/widgets/UpdateGoalSelector.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
|
||||
import messages from '../messages';
|
||||
import { saveCourseGoal } from '../../data';
|
||||
|
||||
function UpdateGoalSelector({
|
||||
courseId,
|
||||
goalOptions,
|
||||
intl,
|
||||
selectedGoal,
|
||||
setGoalToDisplay,
|
||||
setGoalToastHeader,
|
||||
}) {
|
||||
function selectGoalHandler(event) {
|
||||
const key = event.currentTarget.id;
|
||||
const text = event.currentTarget.innerText;
|
||||
const newGoal = {
|
||||
key,
|
||||
text,
|
||||
};
|
||||
|
||||
setGoalToDisplay(newGoal);
|
||||
saveCourseGoal(courseId, key).then((response) => {
|
||||
const { data } = response;
|
||||
const {
|
||||
header,
|
||||
} = data;
|
||||
|
||||
setGoalToastHeader(header);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="mb-4">
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-12 p-0">
|
||||
<label className="h6 m-0" htmlFor="edit-goal-selector">
|
||||
{intl.formatMessage(messages.goal)}
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-12 p-0">
|
||||
<Dropdown className="py-2">
|
||||
<Dropdown.Toggle variant="outline-primary" block id="edit-goal-selector" data-testid="edit-goal-selector">
|
||||
{selectedGoal.text}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{goalOptions.map(([goalKey, goalText]) => (
|
||||
<Dropdown.Item
|
||||
id={goalKey}
|
||||
key={goalKey}
|
||||
onClick={(event) => { selectGoalHandler(event); }}
|
||||
role="button"
|
||||
>
|
||||
{goalText}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
UpdateGoalSelector.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
goalOptions: PropTypes.arrayOf(
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
selectedGoal: PropTypes.shape({
|
||||
key: PropTypes.string,
|
||||
text: PropTypes.string,
|
||||
}).isRequired,
|
||||
setGoalToDisplay: PropTypes.func.isRequired,
|
||||
setGoalToastHeader: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(UpdateGoalSelector);
|
||||
82
src/course-home/outline-tab/widgets/UpgradeCard.jsx
Normal file
82
src/course-home/outline-tab/widgets/UpgradeCard.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import VerifiedCert from '../../../generic/assets/edX_verified_certificate.png';
|
||||
|
||||
function UpgradeCard({ courseId, intl, onLearnMore }) {
|
||||
const { org } = useModel('courses', courseId);
|
||||
const {
|
||||
verifiedMode,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
if (!verifiedMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventProperties = {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.displayed', eventProperties);
|
||||
});
|
||||
|
||||
const logClick = () => {
|
||||
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.clicked', eventProperties);
|
||||
sendTrackingLogEvent('edx.course.enrollment.upgrade.clicked', {
|
||||
location: 'sidebar-message',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mb-4 p-4 outline-sidebar-upgrade-card">
|
||||
<h2 className="h6" id="outline-sidebar-upgrade-header">{intl.formatMessage(messages.upgradeTitle)}</h2>
|
||||
<img
|
||||
alt={intl.formatMessage(messages.certAlt)}
|
||||
src={VerifiedCert}
|
||||
style={{ width: '124px' }}
|
||||
/>
|
||||
<div className="float-right d-flex flex-column align-items-center">
|
||||
<Button
|
||||
variant="success"
|
||||
href={verifiedMode.upgradeUrl}
|
||||
onClick={logClick}
|
||||
>
|
||||
{intl.formatMessage(messages.upgradeButton, {
|
||||
price: verifiedMode.price,
|
||||
symbol: verifiedMode.currencySymbol,
|
||||
})}
|
||||
</Button>
|
||||
{onLearnMore && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={onLearnMore}
|
||||
aria-labelledby="outline-sidebar-upgrade-header"
|
||||
>
|
||||
{intl.formatMessage(messages.learnMore)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
UpgradeCard.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
onLearnMore: PropTypes.func,
|
||||
};
|
||||
|
||||
UpgradeCard.defaultProps = {
|
||||
onLearnMore: null,
|
||||
};
|
||||
|
||||
export default injectIntl(UpgradeCard);
|
||||
4
src/course-home/outline-tab/widgets/UpgradeCard.scss
Normal file
4
src/course-home/outline-tab/widgets/UpgradeCard.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
.outline-sidebar-upgrade-card {
|
||||
border: 1px solid $gray-500;
|
||||
border-top: 5px solid $success-500;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, TransitionReplace } from '@edx/paragon';
|
||||
import truncate from 'truncate-html';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import LmsHtmlFragment from '../LmsHtmlFragment';
|
||||
@@ -21,8 +23,9 @@ function WelcomeMessage({ courseId, intl }) {
|
||||
|
||||
const [display, setDisplay] = useState(true);
|
||||
|
||||
const shortWelcomeMessageHtml = welcomeMessageHtml.length > 200 && `${welcomeMessageHtml.substring(0, 199)}...`;
|
||||
const [showShortMessage, setShowShortMessage] = useState(!!shortWelcomeMessageHtml);
|
||||
const shortWelcomeMessageHtml = truncate(welcomeMessageHtml, 100, { byWords: true, keepWhitespaces: true });
|
||||
const messageCanBeShortened = shortWelcomeMessageHtml.length < welcomeMessageHtml.length;
|
||||
const [showShortMessage, setShowShortMessage] = useState(messageCanBeShortened);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
@@ -34,27 +37,38 @@ function WelcomeMessage({ courseId, intl }) {
|
||||
setDisplay(false);
|
||||
dispatch(dismissWelcomeMessage(courseId));
|
||||
}}
|
||||
>
|
||||
<div className="my-3">
|
||||
<LmsHtmlFragment
|
||||
html={showShortMessage ? shortWelcomeMessageHtml : welcomeMessageHtml}
|
||||
title={intl.formatMessage(messages.welcomeMessage)}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
shortWelcomeMessageHtml && (
|
||||
<div className="d-flex justify-content-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn rounded align-self-center border border-primary bg-white font-weight-bold mb-3"
|
||||
footer={messageCanBeShortened && (
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-12 col-sm-auto p-0">
|
||||
<Button
|
||||
block
|
||||
onClick={() => setShowShortMessage(!showShortMessage)}
|
||||
variant="outline-primary"
|
||||
>
|
||||
{showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton)
|
||||
: intl.formatMessage(messages.welcomeMessageShowLessButton)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<TransitionReplace className="mb-3" enterDuration={200} exitDuration={200}>
|
||||
{showShortMessage ? (
|
||||
<LmsHtmlFragment
|
||||
data-testid="short-welcome-message-iframe"
|
||||
key="short-html"
|
||||
html={shortWelcomeMessageHtml}
|
||||
title={intl.formatMessage(messages.welcomeMessage)}
|
||||
/>
|
||||
) : (
|
||||
<LmsHtmlFragment
|
||||
data-testid="long-welcome-message-iframe"
|
||||
key="full-html"
|
||||
html={welcomeMessageHtml}
|
||||
title={intl.formatMessage(messages.welcomeMessage)}
|
||||
/>
|
||||
)}
|
||||
</TransitionReplace>
|
||||
</Alert>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ 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';
|
||||
import VerifiedCert from '../../generic/assets/edX_verified_certificate.png';
|
||||
|
||||
function CertificateBanner({ intl }) {
|
||||
const {
|
||||
@@ -17,6 +17,7 @@ function CertificateBanner({ intl }) {
|
||||
certificateData,
|
||||
enrollmentMode,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
if (certificateData === null || enrollmentMode === 'audit') { return null; }
|
||||
const { certUrl, certDownloadUrl } = certificateData;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
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);
|
||||
@@ -6,6 +6,7 @@ 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 {
|
||||
@@ -29,6 +30,7 @@ function ProgressTab({ intl }) {
|
||||
</div>
|
||||
)}
|
||||
<CertificateBanner />
|
||||
<CreditRequirements />
|
||||
{coursewareSummary.map((chapter) => (
|
||||
<Chapter
|
||||
key={chapter.displayName}
|
||||
|
||||
@@ -24,6 +24,7 @@ function Subsection({
|
||||
<section className="my-3 ml-3">
|
||||
<div className="row">
|
||||
<a className="h6" href={subsection.url}>
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<div dangerouslySetInnerHTML={{ __html: subsection.displayName }} />
|
||||
{showTotalScore && <span className="sr-only">{totalScoreSr}</span>}
|
||||
</a>
|
||||
|
||||
@@ -56,7 +56,67 @@ const messages = defineMessages({
|
||||
},
|
||||
studioLink: {
|
||||
id: 'learning.progress.badge.studioLink',
|
||||
defaultMessage: 'View grading in studio',
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -125,9 +125,15 @@ class CoursewareContainer extends Component {
|
||||
|
||||
handleUnitNavigationClick = (nextUnitId) => {
|
||||
const {
|
||||
courseId, sequenceId, unitId,
|
||||
courseId, sequenceId,
|
||||
match: {
|
||||
params: {
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
} = this.props;
|
||||
this.props.checkBlockCompletion(courseId, sequenceId, unitId);
|
||||
|
||||
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
|
||||
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
|
||||
}
|
||||
|
||||
@@ -171,7 +177,15 @@ class CoursewareContainer extends Component {
|
||||
}
|
||||
|
||||
renderDenied() {
|
||||
const { courseId, course } = this.props;
|
||||
const {
|
||||
course,
|
||||
courseId,
|
||||
match: {
|
||||
params: {
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
} = this.props;
|
||||
let url = `/redirect/course-home/${courseId}`;
|
||||
switch (course.canLoadCourseware.errorCode) {
|
||||
case 'audit_expired':
|
||||
@@ -186,6 +200,9 @@ class CoursewareContainer extends Component {
|
||||
case 'unfulfilled_milestones':
|
||||
url = '/redirect/dashboard';
|
||||
break;
|
||||
case 'microfrontend_disabled':
|
||||
url = `/redirect/courseware/${courseId}/unit/${routeUnitId}`;
|
||||
break;
|
||||
case 'authentication_required':
|
||||
case 'enrollment_required':
|
||||
default:
|
||||
@@ -256,7 +273,6 @@ CoursewareContainer.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
sequenceId: PropTypes.string,
|
||||
firstSequenceId: PropTypes.string,
|
||||
unitId: PropTypes.string,
|
||||
courseStatus: PropTypes.oneOf(['loaded', 'loading', 'failed', 'denied']).isRequired,
|
||||
sequenceStatus: PropTypes.oneOf(['loaded', 'loading', 'failed']).isRequired,
|
||||
nextSequence: sequenceShape,
|
||||
@@ -273,7 +289,6 @@ CoursewareContainer.defaultProps = {
|
||||
courseId: null,
|
||||
sequenceId: null,
|
||||
firstSequenceId: null,
|
||||
unitId: null,
|
||||
nextSequence: null,
|
||||
previousSequence: null,
|
||||
course: null,
|
||||
@@ -353,13 +368,12 @@ const firstSequenceIdSelector = createSelector(
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
const {
|
||||
courseId, sequenceId, unitId, courseStatus, sequenceStatus,
|
||||
courseId, sequenceId, courseStatus, sequenceStatus,
|
||||
} = state.courseware;
|
||||
|
||||
return {
|
||||
courseId,
|
||||
sequenceId,
|
||||
unitId,
|
||||
courseStatus,
|
||||
sequenceStatus,
|
||||
course: currentCourseSelector(state),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { waitForElementToBeRemoved } from '@testing-library/dom';
|
||||
import { waitForElementToBeRemoved, fireEvent } from '@testing-library/dom';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
@@ -11,7 +11,7 @@ import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { UserMessagesProvider } from '../generic/user-messages';
|
||||
import tabMessages from '../tab-page/messages';
|
||||
import initializeMockApp from '../setupTest';
|
||||
import { initializeMockApp } from '../setupTest';
|
||||
|
||||
import CoursewareContainer from './CoursewareContainer';
|
||||
import buildSimpleCourseBlocks from './data/__factories__/courseBlocks.factory';
|
||||
@@ -33,6 +33,8 @@ jest.mock(
|
||||
() => MockUnit,
|
||||
);
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
initializeMockApp();
|
||||
|
||||
describe('CoursewareContainer', () => {
|
||||
@@ -261,6 +263,26 @@ describe('CoursewareContainer', () => {
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
|
||||
});
|
||||
|
||||
it('should navigate between units and check block completion', async () => {
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
|
||||
const { container } = render(component);
|
||||
|
||||
axiosMock.onPost(`${courseId}/xblock/${sequenceBlock.id}/handler/xmodule_handler/get_completion`).reply(200, {
|
||||
complete: true,
|
||||
});
|
||||
|
||||
// This is an important line that ensures the spinner has been removed - and thus our main
|
||||
// content has been loaded - prior to proceeding with our expectations.
|
||||
await waitForElementToBeRemoved(screen.getByRole('status'));
|
||||
|
||||
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
|
||||
const sequenceNextButton = sequenceNavButtons[4];
|
||||
expect(sequenceNextButton).toHaveTextContent('Next');
|
||||
fireEvent.click(sequenceNavButtons[4]);
|
||||
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${sequenceBlock.id}/${unitBlocks[1].id}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the current sequence is an exam', () => {
|
||||
|
||||
14
src/courseware/CoursewareRedirect.jsx
Normal file
14
src/courseware/CoursewareRedirect.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { useModel } from '../generic/model-store';
|
||||
|
||||
export default function CourseRedirect({ match }) {
|
||||
const {
|
||||
courseId,
|
||||
unitId,
|
||||
} = match.params;
|
||||
const unit = useModel('units', unitId) || {};
|
||||
const coursewareUrl = unit.lmsWebUrl || `${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware/`;
|
||||
global.location.assign(coursewareUrl);
|
||||
return null;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Switch, Route, useRouteMatch } from 'react-router';
|
||||
import { Switch, useRouteMatch } from 'react-router';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import PageLoading from './generic/PageLoading';
|
||||
import { PageRoute } from '@edx/frontend-platform/react';
|
||||
|
||||
import PageLoading from '../generic/PageLoading';
|
||||
import CoursewareRedirect from './CoursewareRedirect';
|
||||
|
||||
export default () => {
|
||||
const { path } = useRouteMatch();
|
||||
@@ -18,13 +21,17 @@ export default () => {
|
||||
/>
|
||||
|
||||
<Switch>
|
||||
<Route
|
||||
<PageRoute
|
||||
path={`${path}/courseware/:courseId/unit/:unitId`}
|
||||
component={CoursewareRedirect}
|
||||
/>
|
||||
<PageRoute
|
||||
path={`${path}/course-home/:courseId`}
|
||||
render={({ match }) => {
|
||||
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/course/`);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
<PageRoute
|
||||
path={`${path}/dashboard`}
|
||||
render={({ location }) => {
|
||||
global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`);
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -11,9 +11,9 @@ import useOfferAlert from '../../alerts/offer-alert';
|
||||
import Sequence from './sequence';
|
||||
|
||||
import { CelebrationModal, shouldCelebrateOnSectionLoad } from './celebration';
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import CourseSock from './course-sock';
|
||||
import ContentTools from './content-tools';
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import CourseSock from '../../generic/course-sock';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
function Course({
|
||||
@@ -50,6 +50,31 @@ function Course({
|
||||
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
||||
const celebrationOpen = shouldCelebrateOnSectionLoad(courseId, sequenceId, unitId, celebrateFirstSection, dispatch);
|
||||
|
||||
// The below block of code should be reverted after the REV1512 experiment
|
||||
const [REV1512FlyoverEnabled, setREV1512FlyoverEnabled] = useState(false);
|
||||
window.enableREV1512Flyover = () => {
|
||||
setREV1512FlyoverEnabled(true);
|
||||
};
|
||||
const getCookie = (name) => {
|
||||
const match = document.cookie.match(`${name}=([^;]*)`);
|
||||
return match ? match[1] : undefined;
|
||||
};
|
||||
const [REV1512FlyoverVisible, setREV1512FlyoverVisible] = useState(getCookie('REV1512FlyoverVisible') === 'true');
|
||||
const isREV1512FlyoverVisible = () => REV1512FlyoverEnabled && (REV1512FlyoverVisible || getCookie('REV1512FlyoverVisible') === 'true');
|
||||
const toggleREV1512Flyover = () => {
|
||||
const setCookie = (cookieName, value, domain, exdays) => {
|
||||
const cookieDomain = (typeof domain === 'undefined') ? '' : `domain=${domain};`;
|
||||
const exdate = new Date();
|
||||
exdate.setDate(exdate.getDate() + exdays);
|
||||
const cookieValue = escape(value) + ((exdays == null) ? '' : `; expires=${exdate.toUTCString()}`);
|
||||
document.cookie = `${cookieName}=${cookieValue};${cookieDomain}path=/`;
|
||||
};
|
||||
const isVisible = isREV1512FlyoverVisible();
|
||||
setCookie('REV1512FlyoverVisible', !isVisible);
|
||||
setREV1512FlyoverVisible(!isVisible);
|
||||
};
|
||||
// The above block of code should be reverted after the REV1512 experiment
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -67,6 +92,8 @@ function Course({
|
||||
courseId={courseId}
|
||||
sectionId={section ? section.id : null}
|
||||
sequenceId={sequenceId}
|
||||
toggleREV1512Flyover={toggleREV1512Flyover} /* This line should be reverted after REV1512 experiment */
|
||||
REV1512FlyoverEnabled={REV1512FlyoverEnabled} /* This line should be reverted after REV1512 experiment */
|
||||
/>
|
||||
<AlertList topic="sequence" />
|
||||
<Sequence
|
||||
@@ -76,6 +103,8 @@ function Course({
|
||||
unitNavigationHandler={unitNavigationHandler}
|
||||
nextSequenceHandler={nextSequenceHandler}
|
||||
previousSequenceHandler={previousSequenceHandler}
|
||||
isREV1512FlyoverVisible={isREV1512FlyoverVisible} /* This line should be reverted after REV1512 experiment */
|
||||
REV1512FlyoverEnabled={REV1512FlyoverEnabled} /* This line should be reverted after REV1512 experiment */
|
||||
/>
|
||||
{celebrationOpen && (
|
||||
<CelebrationModal
|
||||
@@ -83,7 +112,7 @@ function Course({
|
||||
open
|
||||
/>
|
||||
)}
|
||||
{canShowUpgradeSock && verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
|
||||
{canShowUpgradeSock && <CourseSock verifiedMode={verifiedMode} />}
|
||||
<ContentTools course={course} />
|
||||
</>
|
||||
);
|
||||
|
||||
142
src/courseware/course/Course.test.jsx
Normal file
142
src/courseware/course/Course.test.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import {
|
||||
loadUnit, render, screen, waitFor, getByRole, initializeTestStore, fireEvent,
|
||||
} from '../../setupTest';
|
||||
import Course from './Course';
|
||||
import { handleNextSectionCelebration } from './celebration';
|
||||
import * as celebrationUtils from './celebration/utils';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
const recordFirstSectionCelebration = jest.fn();
|
||||
celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
|
||||
|
||||
describe('Course', () => {
|
||||
let store;
|
||||
const mockData = {
|
||||
nextSequenceHandler: () => {},
|
||||
previousSequenceHandler: () => {},
|
||||
unitNavigationHandler: () => {},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
store = await initializeTestStore();
|
||||
const { courseware, models } = store.getState();
|
||||
const { courseId, sequenceId } = courseware;
|
||||
Object.assign(mockData, {
|
||||
courseId,
|
||||
sequenceId,
|
||||
unitId: Object.values(models.units)[0].id,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads learning sequence', async () => {
|
||||
render(<Course {...mockData} />);
|
||||
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Learn About Verified Certificates' })).not.toBeInTheDocument();
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
const { models } = store.getState();
|
||||
const sequence = models.sequences[mockData.sequenceId];
|
||||
const section = models.sections[sequence.sectionId];
|
||||
const course = models.courses[mockData.courseId];
|
||||
expect(document.title).toMatch(
|
||||
`${sequence.title} | ${section.title} | ${course.title} | edX`,
|
||||
);
|
||||
});
|
||||
|
||||
it('displays celebration modal', async () => {
|
||||
// TODO: Remove these console mocks after merging https://github.com/edx/paragon/pull/526.
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const courseMetadata = Factory.build('courseMetadata', { celebrations: { firstSection: true } });
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
const { courseware, models } = testStore.getState();
|
||||
const { courseId, sequenceId } = courseware;
|
||||
const testData = {
|
||||
...mockData,
|
||||
courseId,
|
||||
sequenceId,
|
||||
unitId: Object.values(models.units)[0].id,
|
||||
};
|
||||
// Set up LocalStorage for testing.
|
||||
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
|
||||
const celebrationModal = screen.getByRole('dialog');
|
||||
expect(celebrationModal).toBeInTheDocument();
|
||||
expect(getByRole(celebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays upgrade sock', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', { can_show_upgrade_sock: true });
|
||||
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
||||
|
||||
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
expect(screen.getByRole('button', { name: 'Learn About Verified Certificates' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays offer and expiration alert', async () => {
|
||||
const offerText = 'test-offer';
|
||||
const offerId = `${offerText}-id`;
|
||||
const offerHtml = `<div data-testid="${offerId}">${offerText}</div>`;
|
||||
|
||||
const expirationText = 'test-expiration';
|
||||
const expirationId = `${expirationText}-id`;
|
||||
const expirationHtml = `<div data-testid="${expirationId}">${expirationText}</div>`;
|
||||
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
offer_html: offerHtml,
|
||||
course_expired_message: expirationHtml,
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
||||
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
|
||||
expect(await screen.findByTestId(offerId)).toHaveTextContent(offerText);
|
||||
expect(screen.getByTestId(expirationId)).toHaveTextContent(expirationText);
|
||||
});
|
||||
|
||||
it('passes handlers to the sequence', async () => {
|
||||
const nextSequenceHandler = jest.fn();
|
||||
const previousSequenceHandler = jest.fn();
|
||||
const unitNavigationHandler = jest.fn();
|
||||
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
const unitBlocks = Array.from({ length: 3 }).map(() => Factory.build(
|
||||
'block',
|
||||
{ type: 'vertical' },
|
||||
{ courseId: courseMetadata.id },
|
||||
));
|
||||
const testStore = await initializeTestStore({ courseMetadata, unitBlocks }, false);
|
||||
const { courseware, models } = testStore.getState();
|
||||
const { courseId, sequenceId } = courseware;
|
||||
const testData = {
|
||||
...mockData,
|
||||
courseId,
|
||||
sequenceId,
|
||||
unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests.
|
||||
nextSequenceHandler,
|
||||
previousSequenceHandler,
|
||||
unitNavigationHandler,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
|
||||
// We are in the middle of the sequence, so no
|
||||
expect(previousSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(nextSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
@@ -13,10 +13,10 @@ function CourseBreadcrumb({
|
||||
return (
|
||||
<>
|
||||
{withSeparator && (
|
||||
<li className="mx-2 text-gray-300" role="presentation" aria-hidden>/</li>
|
||||
<li className="mx-2 text-primary-500" role="presentation" aria-hidden>/</li>
|
||||
)}
|
||||
<li {...attrs}>
|
||||
<a href={url}>{children}</a>
|
||||
<a className="text-primary-500" href={url}>{children}</a>
|
||||
</li>
|
||||
</>
|
||||
);
|
||||
@@ -36,6 +36,8 @@ export default function CourseBreadcrumbs({
|
||||
courseId,
|
||||
sectionId,
|
||||
sequenceId,
|
||||
toggleREV1512Flyover, /* This line should be reverted after the REV1512 experiment */
|
||||
REV1512FlyoverEnabled, /* This line should be reverted after the REV1512 experiment */
|
||||
}) {
|
||||
const course = useModel('courses', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
@@ -82,6 +84,22 @@ export default function CourseBreadcrumbs({
|
||||
{label}
|
||||
</CourseBreadcrumb>
|
||||
))}
|
||||
{/* The below block of code should be reverted after the REV1512 experiment */}
|
||||
{REV1512FlyoverEnabled
|
||||
&& (
|
||||
<div
|
||||
className="toggleFlyoverButton"
|
||||
aria-hidden="true"
|
||||
style={{ marginLeft: 'auto', marginTop: '-16px' }}
|
||||
onClick={() => {
|
||||
toggleREV1512Flyover();
|
||||
}}
|
||||
>
|
||||
<svg width="54" height="40" viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="53" height="39" rx="1.5" fill="white" stroke="#E7E8E9" /><path d="M36 20C36 15.6 32.4 12 28 12C27.7 12 27.5 12.2 27.5 12.5C27.5 12.8 27.7 13 28 13C31.85 13 35 16.15 35 20C35 23.85 31.85 27 28 27C24.15 27 21 23.85 21 20C21 19.7 20.8 19.5 20.5 19.5C20.3 19.5 20.1 19.65 20.05 19.8C20 19.85 20 19.95 20 20C20 24.4 23.6 28 28 28C32.4 28 36 24.4 36 20Z" fill="black" stroke="black" strokeWidth="0.6" /><path d="M23.1065 14.52C22.9403 14.36 22.691 14.36 22.5247 14.52C22.3585 14.68 22.3585 14.92 22.5247 15.08C22.6078 15.16 22.7325 15.2 22.8156 15.2C22.9403 15.2 23.0234 15.16 23.1065 15.08C23.2312 14.96 23.2312 14.68 23.1065 14.52Z" fill="black" stroke="black" strokeWidth="0.6" /><path d="M27.6848 15.2C27.3939 15.2 27.2 15.3973 27.2 15.6932V19.6384C27.2 19.6877 27.2 19.7863 27.2484 19.8356C27.2969 19.8849 27.2969 19.9343 27.3454 19.9836L29.5757 22.2521C29.6727 22.3507 29.8181 22.4 29.9151 22.4C30.0121 22.4 30.1575 22.3507 30.2545 22.2521C30.4484 22.0548 30.4484 21.7589 30.2545 21.5617L28.1696 19.4411V15.6932C28.1696 15.3973 27.9757 15.2 27.6848 15.2Z" fill="black" stroke="black" strokeWidth="0.6" /><circle cx="35.5" cy="14.5" r="4.5" fill="#C32D3A" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
@@ -91,6 +109,8 @@ CourseBreadcrumbs.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
sectionId: PropTypes.string,
|
||||
sequenceId: PropTypes.string,
|
||||
toggleREV1512Flyover: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
|
||||
REV1512FlyoverEnabled: PropTypes.bool.isRequired, /* This line should be reverted after the REV1512 experiment */
|
||||
};
|
||||
|
||||
CourseBreadcrumbs.defaultProps = {
|
||||
|
||||
@@ -40,7 +40,8 @@ export default function BookmarkButton({
|
||||
|
||||
return (
|
||||
<StatefulButton
|
||||
className="btn-link px-1 ml-n1 btn-sm"
|
||||
variant="link"
|
||||
className="px-1 ml-n1 btn-sm text-primary-500"
|
||||
onClick={toggleBookmark}
|
||||
state={state}
|
||||
disabledStates={['defaultProcessing', 'bookmarkedProcessing']}
|
||||
|
||||
94
src/courseware/course/bookmark/BookmarkButton.test.jsx
Normal file
94
src/courseware/course/bookmark/BookmarkButton.test.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Factory } from 'rosie';
|
||||
import {
|
||||
render, screen, fireEvent, initializeTestStore, waitFor, authenticatedUser, logUnhandledRequests,
|
||||
} from '../../../setupTest';
|
||||
import { BookmarkButton } from './index';
|
||||
|
||||
describe('Bookmark Button', () => {
|
||||
let axiosMock;
|
||||
let store;
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
const mockData = {
|
||||
isProcessing: false,
|
||||
};
|
||||
const nonBookmarkedUnitBlock = Factory.build(
|
||||
'block',
|
||||
{ type: 'vertical' },
|
||||
{ courseId: courseMetadata.id },
|
||||
);
|
||||
const bookmarkedUnitBlock = Factory.build(
|
||||
'block',
|
||||
{ type: 'vertical', bookmarked: true },
|
||||
{ courseId: courseMetadata.id },
|
||||
);
|
||||
const unitBlocks = [nonBookmarkedUnitBlock, bookmarkedUnitBlock];
|
||||
|
||||
beforeEach(async () => {
|
||||
store = await initializeTestStore({ courseMetadata, unitBlocks });
|
||||
mockData.unitId = nonBookmarkedUnitBlock.id;
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
const bookmarkUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
|
||||
axiosMock.onPost(bookmarkUrl).reply(200, { });
|
||||
|
||||
const bookmarkDeleteUrlRegExp = new RegExp(`${bookmarkUrl}*,*`);
|
||||
axiosMock.onDelete(bookmarkDeleteUrlRegExp).reply(200, { });
|
||||
logUnhandledRequests(axiosMock);
|
||||
});
|
||||
|
||||
it('handles adding bookmark', async () => {
|
||||
render(<BookmarkButton {...mockData} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Bookmark this page' });
|
||||
expect(button).not.toHaveClass('disabled');
|
||||
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
|
||||
expect(axiosMock.history.post[0].data).toEqual(JSON.stringify({ usage_id: nonBookmarkedUnitBlock.id }));
|
||||
expect(store.getState().models.units[nonBookmarkedUnitBlock.id].bookmarked).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not handle adding bookmark when processing', async () => {
|
||||
render(<BookmarkButton {...mockData} isProcessing />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Bookmark this page' });
|
||||
expect(button).toHaveClass('disabled');
|
||||
|
||||
fireEvent.click(button);
|
||||
// HACK: We don't have a function we could reliably await here, so this test relies on the timeout of `waitFor`.
|
||||
await expect(waitFor(
|
||||
() => expect(axiosMock.history.post).toHaveLength(1),
|
||||
{ timeout: 100 },
|
||||
)).rejects.toThrowError(/expect.*toHaveLength.*/);
|
||||
expect(store.getState().models.units[nonBookmarkedUnitBlock.id].bookmarked).toBeFalsy();
|
||||
});
|
||||
|
||||
it('handles removing bookmark', async () => {
|
||||
render(<BookmarkButton {...mockData} unitId={bookmarkedUnitBlock.id} isBookmarked />);
|
||||
const button = screen.getByRole('button', { name: 'Bookmarked' });
|
||||
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(axiosMock.history.delete).toHaveLength(1));
|
||||
expect(axiosMock.history.delete[0].url).toContain(`${authenticatedUser.username},${bookmarkedUnitBlock.id}`);
|
||||
expect(store.getState().models.units[bookmarkedUnitBlock.id].bookmarked).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not handle removing bookmark when processing', async () => {
|
||||
render(<BookmarkButton {...mockData} unitId={bookmarkedUnitBlock.id} isBookmarked isProcessing />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Bookmarked' });
|
||||
expect(button).toHaveClass('disabled');
|
||||
|
||||
fireEvent.click(button);
|
||||
// HACK: We don't have a function we could reliably await here, so this test relies on the timeout of `waitFor`.
|
||||
await expect(waitFor(
|
||||
() => expect(axiosMock.history.delete).toHaveLength(1),
|
||||
{ timeout: 100 },
|
||||
)).rejects.toThrowError(/expect.*toHaveLength.*/);
|
||||
expect(store.getState().models.units[bookmarkedUnitBlock.id].bookmarked).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import * as thunks from './thunks';
|
||||
|
||||
import executeThunk from '../../../../utils';
|
||||
|
||||
import initializeMockApp from '../../../../setupTest';
|
||||
import { initializeMockApp } from '../../../../setupTest';
|
||||
import initializeStore from '../../../../store';
|
||||
|
||||
const { loggingService } = initializeMockApp();
|
||||
|
||||
@@ -7,12 +7,15 @@ import { layoutGenerator } from 'react-break';
|
||||
import ClapsMobile from './assets/claps_280x201.gif';
|
||||
import ClapsTablet from './assets/claps_456x328.gif';
|
||||
import messages from './messages';
|
||||
import SocialIcons from './SocialIcons';
|
||||
import SocialIcons from '../../social-share/SocialIcons';
|
||||
import { recordFirstSectionCelebration } from './utils';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
function CelebrationModal({
|
||||
courseId, intl, open, ...rest
|
||||
}) {
|
||||
const { org } = useModel('courses', courseId);
|
||||
|
||||
const layout = layoutGenerator({
|
||||
mobile: 0,
|
||||
tablet: 400,
|
||||
@@ -23,7 +26,7 @@ function CelebrationModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
recordFirstSectionCelebration(courseId);
|
||||
recordFirstSectionCelebration(org, courseId);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -41,7 +44,12 @@ function CelebrationModal({
|
||||
<p className="mt-3">
|
||||
<strong>{intl.formatMessage(messages.earned)}</strong> {intl.formatMessage(messages.share)}
|
||||
</p>
|
||||
<SocialIcons courseId={courseId} />
|
||||
<SocialIcons
|
||||
analyticsId="edx.ui.lms.celebration.social_share.clicked"
|
||||
courseId={courseId}
|
||||
emailSubject={messages.emailSubject}
|
||||
socialMessage={messages.socialMessage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
closeText={intl.formatMessage(messages.forward)}
|
||||
|
||||
@@ -13,14 +13,9 @@ const messages = defineMessages({
|
||||
id: 'learning.celebration.earned',
|
||||
defaultMessage: 'You earned it!',
|
||||
},
|
||||
emailBody: {
|
||||
id: 'learning.celebration.emailBody',
|
||||
defaultMessage: 'What are you spending your time learning?',
|
||||
description: 'Body when sharing course progress via email',
|
||||
},
|
||||
emailSubject: {
|
||||
id: 'learning.celebration.emailSubject',
|
||||
defaultMessage: "I'm on my way to completing {title} online with @edxonline!",
|
||||
defaultMessage: "I'm on my way to completing {title} online with {platform}!",
|
||||
description: 'Subject when sharing course progress via email',
|
||||
},
|
||||
forward: {
|
||||
@@ -32,15 +27,7 @@ const messages = defineMessages({
|
||||
id: 'learning.celebration.share',
|
||||
defaultMessage: 'Take a moment to celebrate and share your progress.',
|
||||
},
|
||||
shareEmail: {
|
||||
id: 'learning.celebration.share.email',
|
||||
defaultMessage: 'Share your progress via email.',
|
||||
},
|
||||
shareService: {
|
||||
id: 'learning.celebration.share.service',
|
||||
defaultMessage: 'Share your progress on {service}.',
|
||||
},
|
||||
social: {
|
||||
socialMessage: {
|
||||
id: 'learning.celebration.social',
|
||||
defaultMessage: 'I’m on my way to completing {title} online with {platform}. What are you spending your time learning?',
|
||||
description: 'Shown when sharing course progress on a social network',
|
||||
|
||||
@@ -16,14 +16,16 @@ function handleNextSectionCelebration(sequenceId, nextSequenceId, nextUnitId) {
|
||||
});
|
||||
}
|
||||
|
||||
function recordFirstSectionCelebration(courseId) {
|
||||
function recordFirstSectionCelebration(org, courseId) {
|
||||
// Tell the LMS
|
||||
postFirstSectionCelebrationComplete(courseId);
|
||||
|
||||
// Tell our analytics
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
sendTrackEvent('edx.ui.lms.celebration.first_section.opened', {
|
||||
course_id: courseId,
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
course_id: courseId, // should only be courserun_key, but left as-is for historical reasons
|
||||
is_staff: administrator,
|
||||
});
|
||||
}
|
||||
|
||||
42
src/courseware/course/content-tools/ContentTools.test.jsx
Normal file
42
src/courseware/course/content-tools/ContentTools.test.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { initializeTestStore, render, screen } from '../../../setupTest';
|
||||
import ContentTools from './ContentTools';
|
||||
|
||||
jest.mock('./calculator/Calculator', () => () => <div data-testid="Calculator" />);
|
||||
jest.mock('./notes-visibility/NotesVisibility', () => () => <div data-testid="NotesVisibility" />);
|
||||
|
||||
describe('Content Tools', () => {
|
||||
const mockData = {
|
||||
course: {
|
||||
notes: { enabled: false },
|
||||
showCalculator: false,
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true });
|
||||
});
|
||||
|
||||
it('hides content tools', () => {
|
||||
const { container } = render(<ContentTools {...mockData} />);
|
||||
expect(container.getElementsByClassName('d-flex')[0]).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('displays Calculator', () => {
|
||||
const testData = JSON.parse(JSON.stringify(mockData));
|
||||
testData.course.showCalculator = true;
|
||||
render(<ContentTools {...testData} />);
|
||||
|
||||
expect(screen.getByTestId('Calculator')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('NotesVisibility')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays Notes Visibility', () => {
|
||||
const testData = JSON.parse(JSON.stringify(mockData));
|
||||
testData.course.notes.enabled = true;
|
||||
render(<ContentTools {...testData} />);
|
||||
|
||||
expect(screen.getByTestId('NotesVisibility')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('Calculator')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -98,21 +98,13 @@ class Calculator extends Component {
|
||||
<FormattedMessage
|
||||
tagName="h6"
|
||||
id="calculator.instructions"
|
||||
defaultMessage="For detailed information, see {expressions_link} in the {edx_guide}."
|
||||
defaultMessage="For detailed information, see the {expressions_link}."
|
||||
values={{
|
||||
expressions_link: (
|
||||
<a href="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/completing_assignments/SFD_mathformatting.html#math-formatting">
|
||||
<a href={getConfig().SUPPORT_URL_CALCULATOR_MATH}>
|
||||
<FormattedMessage
|
||||
id="calculator.instructions.expressions.link.title"
|
||||
defaultMessage="Entering Mathematical and Scientific Expressions"
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
edx_guide: (
|
||||
<a href="https://edx-guide-for-students.readthedocs.io/en/latest/index.html">
|
||||
<FormattedMessage
|
||||
id="calculator.instructions.edx.guide.link.title"
|
||||
defaultMessage="edX Guide for Students"
|
||||
id="calculator.instructions.support.title"
|
||||
defaultMessage="Help Center"
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import Calculator from './Calculator';
|
||||
import {
|
||||
initializeTestStore, render, screen, fireEvent, waitFor, logUnhandledRequests,
|
||||
} from '../../../../setupTest';
|
||||
|
||||
describe('Calculator', () => {
|
||||
let axiosMock;
|
||||
let equationUrl;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true });
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
equationUrl = new RegExp(`${getConfig().LMS_BASE_URL}/calculate*`);
|
||||
});
|
||||
|
||||
it('expands on click', () => {
|
||||
render(<Calculator />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Calculate' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Calculator Instructions' })).not.toBeInTheDocument();
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Calculator' });
|
||||
expect(button.querySelector('svg')).toHaveClass('fa-calculator');
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(button.querySelector('svg')).toHaveClass('fa-times-circle');
|
||||
expect(screen.getByRole('button', { name: 'Calculate' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Calculator Instructions' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(button.querySelector('svg')).toHaveClass('fa-calculator');
|
||||
});
|
||||
|
||||
it('displays instructions on click', () => {
|
||||
render(<Calculator />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Calculator' });
|
||||
fireEvent.click(button);
|
||||
|
||||
const instructionsButton = screen.getByRole('button', { name: 'Calculator Instructions' });
|
||||
expect(instructionsButton.querySelector('svg')).toHaveClass('fa-question-circle');
|
||||
expect(screen.queryByText(/For detailed information, see/)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(instructionsButton);
|
||||
expect(instructionsButton.querySelector('svg')).toHaveClass('fa-times-circle');
|
||||
expect(screen.getByText(/For detailed information, see/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(instructionsButton);
|
||||
expect(instructionsButton.querySelector('svg')).toHaveClass('fa-question-circle');
|
||||
});
|
||||
|
||||
it('handles submitting equation', async () => {
|
||||
const equation = 'log2(2^10)';
|
||||
const result = '10';
|
||||
|
||||
axiosMock.reset();
|
||||
axiosMock.onGet(equationUrl).reply(200, { result });
|
||||
logUnhandledRequests(axiosMock);
|
||||
|
||||
render(<Calculator />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Calculator' }));
|
||||
const input = screen.getByRole('textbox', { name: 'Calculator Input' });
|
||||
const output = screen.getByRole('textbox', { name: 'Calculator Result' });
|
||||
const submitButton = screen.getByRole('button', { name: 'Calculate' });
|
||||
|
||||
fireEvent.change(input, { target: { value: equation } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
|
||||
expect(axiosMock.history.get[0].url).toContain(escape(equation));
|
||||
|
||||
expect(output).toHaveValue(result);
|
||||
});
|
||||
});
|
||||
@@ -57,11 +57,10 @@ class NotesVisibility extends Component {
|
||||
NotesVisibility.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
course: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
notes: PropTypes.shape({
|
||||
enabled: PropTypes.bool,
|
||||
visible: PropTypes.bool,
|
||||
}),
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { waitFor } from '@testing-library/dom';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
fireEvent, initializeTestStore, logUnhandledRequests, render, screen,
|
||||
} from '../../../../setupTest';
|
||||
import NotesVisibility from './NotesVisibility';
|
||||
|
||||
const originalConfig = jest.requireActual('@edx/frontend-platform').getConfig();
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform'),
|
||||
getConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Notes Visibility', () => {
|
||||
let axiosMock;
|
||||
let visibilityUrl;
|
||||
const mockData = {
|
||||
course: {
|
||||
id: 'test-course',
|
||||
notes: {
|
||||
visible: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true });
|
||||
|
||||
// Mock `targetOrigin` of the `postMessage`.
|
||||
getConfig.mockImplementation(() => originalConfig);
|
||||
const config = { ...originalConfig };
|
||||
config.LMS_BASE_URL = `${window.location.protocol}//${window.location.host}`;
|
||||
getConfig.mockImplementation(() => config);
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
visibilityUrl = `${config.LMS_BASE_URL}/courses/${mockData.course.id}/edxnotes/visibility/`;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock.reset();
|
||||
axiosMock.onPut(visibilityUrl).reply(200);
|
||||
logUnhandledRequests(axiosMock);
|
||||
});
|
||||
|
||||
it('hides notes', () => {
|
||||
render(<NotesVisibility {...mockData} />);
|
||||
|
||||
const button = screen.getByRole('switch', { name: 'Show Notes' });
|
||||
expect(button)
|
||||
.not.toBeChecked()
|
||||
.toHaveClass('text-success');
|
||||
expect(button.querySelector('svg'))
|
||||
.toHaveClass('fa-pencil-alt')
|
||||
.toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('shows notes', () => {
|
||||
const testData = JSON.parse(JSON.stringify(mockData));
|
||||
testData.course.notes.visible = true;
|
||||
render(<NotesVisibility {...testData} />);
|
||||
|
||||
const button = screen.getByRole('switch', { name: 'Hide Notes' });
|
||||
expect(button)
|
||||
.toBeChecked()
|
||||
.toHaveClass('text-secondary');
|
||||
expect(button.querySelector('svg'))
|
||||
.toHaveClass('fa-pencil-alt')
|
||||
.toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('handles click', async () => {
|
||||
const mockFn = jest.fn();
|
||||
const frame = document.createElement('iframe');
|
||||
frame.id = 'unit-iframe';
|
||||
const { container } = render(<NotesVisibility {...mockData} />);
|
||||
|
||||
container.appendChild(frame);
|
||||
frame.contentWindow.addEventListener('message', e => {
|
||||
mockFn(e.data);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Show Notes' }));
|
||||
await waitFor(() => expect(mockFn).toHaveBeenCalled());
|
||||
expect(mockFn)
|
||||
.toHaveBeenCalledTimes(1)
|
||||
.toHaveBeenCalledWith('tools.toggleNotes');
|
||||
|
||||
expect(axiosMock.history.put).toHaveLength(1);
|
||||
expect(axiosMock.history.put[0].url).toEqual(visibilityUrl);
|
||||
expect(axiosMock.history.put[0].data).toEqual(`{"visibility":${mockData.course.notes.visible}}`);
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'Hide Notes' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
336
src/courseware/course/course-exit/CourseCelebration.jsx
Normal file
336
src/courseware/course/course-exit/CourseCelebration.jsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faLinkedinIn } from '@fortawesome/free-brands-svg-icons';
|
||||
|
||||
import {
|
||||
FormattedDate, FormattedMessage, injectIntl, intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { layoutGenerator } from 'react-break';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Alert, Button, Hyperlink } from '@edx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
import CelebrationMobile from './assets/celebration_456x328.gif';
|
||||
import CelebrationDesktop from './assets/celebration_750x540.gif';
|
||||
import certificate from '../../../generic/assets/edX_verified_certificate.png';
|
||||
import certificateLocked from '../../../generic/assets/edX_locked_verified_certificate.png';
|
||||
import messages from './messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { requestCert } from '../../../course-home/data/thunks';
|
||||
import ProgramCompletion from './ProgramCompletion';
|
||||
import DashboardFootnote from './DashboardFootnote';
|
||||
import UpgradeFootnote from './UpgradeFootnote';
|
||||
import SocialIcons from '../../social-share/SocialIcons';
|
||||
import { logClick, logVisit } from './utils';
|
||||
|
||||
const LINKEDIN_BLUE = '#2867B2';
|
||||
|
||||
function CourseCelebration({ intl }) {
|
||||
const layout = layoutGenerator({
|
||||
mobile: 0,
|
||||
tablet: 768,
|
||||
});
|
||||
|
||||
const OnMobile = layout.is('mobile');
|
||||
const OnAtLeastTablet = layout.isAtLeast('tablet');
|
||||
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
certificateData,
|
||||
end,
|
||||
linkedinAddToProfileUrl,
|
||||
org,
|
||||
relatedPrograms,
|
||||
verifiedMode,
|
||||
verifyIdentityUrl,
|
||||
verificationStatus,
|
||||
} = useModel('courses', courseId);
|
||||
|
||||
const {
|
||||
certStatus,
|
||||
certWebViewUrl,
|
||||
downloadUrl,
|
||||
} = certificateData || {};
|
||||
|
||||
const { administrator, username } = getAuthenticatedUser();
|
||||
|
||||
const dashboardLink = (
|
||||
<Hyperlink
|
||||
className="text-gray-700"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().LMS_BASE_URL}/dashboard`}
|
||||
>
|
||||
{intl.formatMessage(messages.dashboardLink)}
|
||||
</Hyperlink>
|
||||
);
|
||||
const idVerificationSupportLink = getConfig().SUPPORT_URL_ID_VERIFICATION && (
|
||||
<Hyperlink
|
||||
className="text-gray-700"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={getConfig().SUPPORT_URL_ID_VERIFICATION}
|
||||
>
|
||||
{intl.formatMessage(messages.idVerificationSupportLink)}
|
||||
</Hyperlink>
|
||||
);
|
||||
const profileLink = (
|
||||
<Hyperlink
|
||||
className="text-gray-700"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().LMS_BASE_URL}/u/${username}`}
|
||||
>
|
||||
{intl.formatMessage(messages.profileLink)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
let buttonLocation;
|
||||
let buttonText;
|
||||
let buttonVariant = 'outline-primary';
|
||||
let buttonEvent = null;
|
||||
let certificateImage = certificate;
|
||||
let footnote;
|
||||
let message;
|
||||
let certHeader;
|
||||
let visitEvent = 'celebration_generic';
|
||||
// These cases are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
|
||||
switch (certStatus) {
|
||||
case 'downloadable':
|
||||
certHeader = intl.formatMessage(messages.certificateHeaderDownloadable);
|
||||
message = (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="courseCelebration.certificateBody.available"
|
||||
defaultMessage="
|
||||
Showcase your accomplishment on LinkedIn or your resumé today.
|
||||
You can download your certificate now and access it any time from your
|
||||
{dashboardLink} and {profileLink}."
|
||||
values={{ dashboardLink, profileLink }}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
if (certWebViewUrl) {
|
||||
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
||||
buttonText = intl.formatMessage(messages.viewCertificateButton);
|
||||
} else if (downloadUrl) {
|
||||
buttonLocation = downloadUrl;
|
||||
buttonText = intl.formatMessage(messages.downloadButton);
|
||||
}
|
||||
buttonEvent = 'view_cert';
|
||||
visitEvent = 'celebration_with_cert';
|
||||
footnote = <DashboardFootnote />;
|
||||
break;
|
||||
case 'earned_but_not_available': {
|
||||
const endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />;
|
||||
certHeader = intl.formatMessage(messages.certificateHeaderNotAvailable);
|
||||
message = (
|
||||
<>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="courseCelebration.certificateBody.notAvailable.endDate"
|
||||
defaultMessage="After this course officially ends on {endDate}, you will receive an
|
||||
email notification with your certificate. Once you have your certificate, be sure
|
||||
to showcase your accomplishment on LinkedIn or your resumé."
|
||||
values={{ endDate }}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="courseCelebration.certificateBody.notAvailable.accessCertificate"
|
||||
defaultMessage="You will be able to access your certificate any time from your
|
||||
{dashboardLink} and {profileLink}."
|
||||
values={{ dashboardLink, profileLink }}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
visitEvent = 'celebration_with_unavailable_cert';
|
||||
footnote = <DashboardFootnote />;
|
||||
break;
|
||||
}
|
||||
case 'requesting':
|
||||
buttonText = intl.formatMessage(messages.requestCertificateButton);
|
||||
buttonEvent = 'request_cert';
|
||||
certHeader = intl.formatMessage(messages.certificateHeaderRequestable);
|
||||
message = (<p>{intl.formatMessage(messages.requestCertificateBodyText)}</p>);
|
||||
visitEvent = 'celebration_with_requestable_cert';
|
||||
footnote = <DashboardFootnote />;
|
||||
break;
|
||||
case 'unverified':
|
||||
certHeader = intl.formatMessage(messages.certificateHeaderUnverified);
|
||||
visitEvent = 'celebration_unverified';
|
||||
footnote = <DashboardFootnote />;
|
||||
if (verificationStatus === 'pending') {
|
||||
message = (<p>{intl.formatMessage(messages.verificationPending)}</p>);
|
||||
} else {
|
||||
buttonText = intl.formatMessage(messages.verifyIdentityButton);
|
||||
buttonEvent = 'verify_id';
|
||||
buttonLocation = verifyIdentityUrl;
|
||||
// todo: check for idVerificationSupportLink null
|
||||
message = (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="courseCelebration.certificateBody.unverified"
|
||||
defaultMessage="In order to generate a certificate, you must complete ID verification.
|
||||
{idVerificationSupportLink} now."
|
||||
values={{ idVerificationSupportLink }}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'audit_passing':
|
||||
case 'honor_passing':
|
||||
if (verifiedMode) {
|
||||
certHeader = intl.formatMessage(messages.certificateHeaderUpgradable);
|
||||
message = (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="courseCelebration.certificateBody.upgradable"
|
||||
defaultMessage="It’s not too late to upgrade. For {price} you will unlock access to all graded
|
||||
assignments in this course. Upon completion, you will receive a verified certificate which is a
|
||||
valuable credential to improve your job prospects and advance your career, or highlight your
|
||||
certificate in school applications."
|
||||
values={{ price: verifiedMode.currencySymbol + verifiedMode.price }}
|
||||
/>
|
||||
<br />
|
||||
{getConfig().SUPPORT_URL_VERIFIED_CERTIFICATE && (
|
||||
<Hyperlink
|
||||
className="text-gray-700"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={getConfig().SUPPORT_URL_VERIFIED_CERTIFICATE}
|
||||
>
|
||||
{intl.formatMessage(messages.verifiedCertificateSupportLink)}
|
||||
</Hyperlink>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
buttonText = intl.formatMessage(messages.upgradeButton);
|
||||
buttonEvent = 'upgrade';
|
||||
buttonLocation = verifiedMode.upgradeUrl;
|
||||
buttonVariant = 'primary';
|
||||
certificateImage = certificateLocked;
|
||||
visitEvent = 'celebration_upgrade';
|
||||
if (verifiedMode.accessExpirationDate) {
|
||||
footnote = <UpgradeFootnote deadline={verifiedMode.accessExpirationDate} href={verifiedMode.upgradeUrl} />;
|
||||
} else {
|
||||
footnote = <DashboardFootnote />;
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
useEffect(() => logVisit(org, courseId, administrator, visitEvent), [org, courseId, administrator, visitEvent]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{`${intl.formatMessage(messages.congratulationsHeader)} | ${getConfig().SITE_NAME}`}</title>
|
||||
</Helmet>
|
||||
<div className="row w-100 mx-0 mb-4 px-5 py-4 border border-light">
|
||||
<div className="col-12 p-0 h2 text-center">
|
||||
{intl.formatMessage(messages.congratulationsHeader)}
|
||||
</div>
|
||||
<div className="col-12 p-0 font-weight-normal lead text-center">
|
||||
{intl.formatMessage(messages.shareHeader)}
|
||||
<SocialIcons
|
||||
analyticsId="edx.ui.lms.course_exit.social_share.clicked"
|
||||
className="mt-2"
|
||||
courseId={courseId}
|
||||
emailSubject={messages.socialMessage}
|
||||
socialMessage={messages.socialMessage}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 mt-3 mb-4 px-0 px-md-5 text-center">
|
||||
<OnMobile>
|
||||
<img
|
||||
src={CelebrationMobile}
|
||||
alt={`${intl.formatMessage(messages.congratulationsImage)}`}
|
||||
className="img-fluid"
|
||||
/>
|
||||
</OnMobile>
|
||||
<OnAtLeastTablet>
|
||||
<img
|
||||
src={CelebrationDesktop}
|
||||
alt={`${intl.formatMessage(messages.congratulationsImage)}`}
|
||||
className="img-fluid"
|
||||
style={{ width: '36rem' }}
|
||||
/>
|
||||
</OnAtLeastTablet>
|
||||
</div>
|
||||
<div className="col-12 px-0 px-md-5">
|
||||
{certHeader && (
|
||||
<Alert variant="primary" className="row w-100 m-0">
|
||||
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
|
||||
<div className="h4">{certHeader}</div>
|
||||
{message}
|
||||
{/* The requesting status needs a different button because it does a POST instead of a GET */}
|
||||
{certStatus === 'requesting' && (
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
onClick={() => {
|
||||
logClick(org, courseId, administrator, buttonEvent);
|
||||
dispatch(requestCert(courseId));
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
{certStatus === 'downloadable' && linkedinAddToProfileUrl && (
|
||||
<Button
|
||||
className="mr-3 mt-2"
|
||||
href={linkedinAddToProfileUrl}
|
||||
onClick={() => logClick(org, courseId, administrator, 'linkedin_add_to_profile')}
|
||||
style={{ backgroundColor: LINKEDIN_BLUE, border: 'none' }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLinkedinIn} className="mr-3" />
|
||||
{`${intl.formatMessage(messages.linkedinAddToProfileButton)}`}
|
||||
</Button>
|
||||
)}
|
||||
{buttonLocation && (
|
||||
<Button
|
||||
className="mt-2"
|
||||
variant={buttonVariant}
|
||||
href={buttonLocation}
|
||||
onClick={() => logClick(org, courseId, administrator, buttonEvent)}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{certStatus !== 'unverified' && (
|
||||
<div className="col-12 order-0 col-md-3 order-md-1 w-100 mb-3 p-0 text-center">
|
||||
<img
|
||||
src={certificateImage}
|
||||
alt={`${intl.formatMessage(messages.certificateImage)}`}
|
||||
className="w-100"
|
||||
style={{ maxWidth: '13rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
{relatedPrograms && relatedPrograms.map(program => (
|
||||
<ProgramCompletion
|
||||
key={program.uuid}
|
||||
progress={program.progress}
|
||||
title={program.title}
|
||||
type={program.slug}
|
||||
url={program.url}
|
||||
/>
|
||||
))}
|
||||
{footnote}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CourseCelebration.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseCelebration);
|
||||
46
src/courseware/course/course-exit/CourseExit.jsx
Normal file
46
src/courseware/course/course-exit/CourseExit.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
import CourseCelebration from './CourseCelebration';
|
||||
import CourseNonPassing from './CourseNonPassing';
|
||||
import { COURSE_EXIT_MODES, getCourseExitMode } from './utils';
|
||||
import messages from './messages';
|
||||
|
||||
function CourseExit({ intl }) {
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
const mode = getCourseExitMode(courseId);
|
||||
|
||||
let body = null;
|
||||
if (mode === COURSE_EXIT_MODES.nonPassing) {
|
||||
body = (<CourseNonPassing />);
|
||||
} else if (mode === COURSE_EXIT_MODES.celebration) {
|
||||
body = (<CourseCelebration />);
|
||||
} else {
|
||||
return (<Redirect to={`/course/${courseId}`} />);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="row w-100 mt-2 mb-4 justify-content-end">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
href={`${getConfig().LMS_BASE_URL}/dashboard`}
|
||||
>
|
||||
{intl.formatMessage(messages.viewCoursesButton)}
|
||||
</Button>
|
||||
</div>
|
||||
{body}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CourseExit.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseExit);
|
||||
290
src/courseware/course/course-exit/CourseExit.test.jsx
Normal file
290
src/courseware/course/course-exit/CourseExit.test.jsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import React from 'react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { fetchCourse } from '../../data';
|
||||
import buildSimpleCourseBlocks from '../../data/__factories__/courseBlocks.factory';
|
||||
import {
|
||||
initializeMockApp, logUnhandledRequests, render, screen,
|
||||
} from '../../../setupTest';
|
||||
import initializeStore from '../../../store';
|
||||
import executeThunk from '../../../utils';
|
||||
import CourseCelebration from './CourseCelebration';
|
||||
import CourseExit from './CourseExit';
|
||||
import CourseNonPassing from './CourseNonPassing';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('Course Exit Pages', () => {
|
||||
let axiosMock;
|
||||
const store = initializeStore();
|
||||
const defaultMetadata = Factory.build('courseMetadata', {
|
||||
user_has_passing_grade: true,
|
||||
end: '2014-02-05T05:00:00Z',
|
||||
});
|
||||
const defaultCourseBlocks = buildSimpleCourseBlocks(defaultMetadata.id, defaultMetadata.name);
|
||||
|
||||
const courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${defaultMetadata.id}`;
|
||||
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
|
||||
|
||||
function setMetadata(attributes) {
|
||||
const courseMetadata = { ...defaultMetadata, ...attributes };
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
async function fetchAndRender(component) {
|
||||
await executeThunk(fetchCourse(defaultMetadata.id), store.dispatch);
|
||||
render(component, { store });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, defaultCourseBlocks);
|
||||
|
||||
logUnhandledRequests(axiosMock);
|
||||
});
|
||||
|
||||
describe('Course Exit routing', () => {
|
||||
it('Routes to celebration for a celebration status', async () => {
|
||||
setMetadata({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
cert_web_view_url: '/certificates/cooluuidgoeshere',
|
||||
},
|
||||
});
|
||||
await fetchAndRender(<CourseExit />);
|
||||
expect(screen.getByText('Congratulations!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Routes to Non-passing experience for a learner with non-passing grade', async () => {
|
||||
setMetadata({
|
||||
certificate_data: {
|
||||
cert_status: 'unverified',
|
||||
},
|
||||
user_has_passing_grade: false,
|
||||
});
|
||||
await fetchAndRender(<CourseExit />);
|
||||
expect(screen.getByText('You’ve reached the end of the course!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Redirects if it does not match any statuses', async () => {
|
||||
setMetadata({
|
||||
certificate_data: {
|
||||
cert_status: 'bogus_status',
|
||||
},
|
||||
});
|
||||
await fetchAndRender(<CourseExit />);
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${defaultMetadata.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Celebration Experience', () => {
|
||||
it('Displays download link', async () => {
|
||||
setMetadata({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
download_url: 'fake.download.url',
|
||||
},
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays webview link', async () => {
|
||||
setMetadata({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
cert_web_view_url: '/certificates/cooluuidgoeshere',
|
||||
},
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByRole('link', { name: 'View my certificate' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'Sample certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays certificate is earned but unavailable message', async () => {
|
||||
setMetadata({ certificate_data: { cert_status: 'earned_but_not_available' } });
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByText('Your certificate will be available soon!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays request certificate link', async () => {
|
||||
setMetadata({ certificate_data: { cert_status: 'requesting' } });
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByRole('button', { name: 'Request certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays social share icons', async () => {
|
||||
setMetadata({ certificate_data: { cert_status: 'unverified' }, marketing_url: 'https://edx.org' });
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByRole('button', { name: 'linkedin' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'facebook' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'twitter' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'email' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Does not display social share icons if no marketing URL', async () => {
|
||||
setMetadata({ certificate_data: { cert_status: 'unverified' } });
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.queryByRole('button', { name: 'linkedin' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'facebook' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'twitter' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'email' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays verify identity link', async () => {
|
||||
setMetadata({
|
||||
certificate_data: { cert_status: 'unverified' },
|
||||
verify_identity_url: `${getConfig().LMS_BASE_URL}/verify_student/verify-now/${defaultMetadata.id}/`,
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByRole('link', { name: 'Verify ID now' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('img', { name: 'Sample certificate' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays verification pending message', async () => {
|
||||
setMetadata({
|
||||
certificate_data: { cert_status: 'unverified' },
|
||||
verification_status: 'pending',
|
||||
verify_identity_url: `${getConfig().LMS_BASE_URL}/verify_student/verify-now/${defaultMetadata.id}/`,
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByText('Your ID verification is pending and your certificate will be available once approved.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Verify ID now' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('img', { name: 'Sample certificate' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays upgrade link when available', async () => {
|
||||
setMetadata({
|
||||
certificate_data: { cert_status: 'audit_passing' },
|
||||
verified_mode: {
|
||||
access_expiration_date: '9999-08-06T12:00:00Z',
|
||||
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
price: 600,
|
||||
currency_symbol: '€',
|
||||
},
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
// Keep these text checks in sync with "audit only" test below, so it doesn't end up checking for text that is
|
||||
// never actually there, when/if the text changes.
|
||||
expect(screen.getByText('Upgrade to pursue a verified certificate')).toBeInTheDocument();
|
||||
expect(screen.getByText('For €600 you will unlock access', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Upgrade now' })).toBeInTheDocument();
|
||||
const node = screen.getByText('Access to this course and its materials', { exact: false });
|
||||
expect(node.textContent).toMatch(/until August 6, 9999\./);
|
||||
});
|
||||
|
||||
it('Displays nothing if audit only', async () => {
|
||||
setMetadata({
|
||||
certificate_data: { cert_status: 'audit_passing' },
|
||||
verified_mode: null,
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
// Keep these queries in sync with "upgrade link" test above, so we don't end up checking for text that is
|
||||
// never actually there, when/if the text changes.
|
||||
expect(screen.queryByText('Upgrade to pursue a verified certificate')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Upgrade now' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays LinkedIn Add to Profile button', async () => {
|
||||
setMetadata({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
cert_web_view_url: '/certificates/cooluuidgoeshere',
|
||||
},
|
||||
linkedin_add_to_profile_url: 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME¶ms',
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByRole('link', { name: 'View my certificate' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Add to LinkedIn profile' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Program Completion experience', () => {
|
||||
beforeEach(() => {
|
||||
setMetadata({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
cert_web_view_url: '/certificates/cooluuidgoeshere',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Does not render ProgramCompletion no related programs', async () => {
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.queryByTestId('program-completion')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Does not render ProgramCompletion if program is incomplete', async () => {
|
||||
setMetadata({
|
||||
related_programs: [{
|
||||
progress: {
|
||||
completed: 1,
|
||||
in_progress: 1,
|
||||
not_started: 1,
|
||||
},
|
||||
slug: 'micromasters',
|
||||
title: 'Example MicroMasters Program',
|
||||
uuid: '123456',
|
||||
url: 'http://localhost:18000/dashboard/programs/123456',
|
||||
}],
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
|
||||
expect(screen.queryByTestId('program-completion')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Renders ProgramCompletion if program is complete', async () => {
|
||||
setMetadata({
|
||||
related_programs: [{
|
||||
progress: {
|
||||
completed: 3,
|
||||
in_progress: 0,
|
||||
not_started: 0,
|
||||
},
|
||||
slug: 'micromasters',
|
||||
title: 'Example MicroMasters Program',
|
||||
uuid: '123456',
|
||||
url: 'http://localhost:18000/dashboard/programs/123456',
|
||||
}],
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
|
||||
expect(screen.queryByTestId('program-completion')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('micromasters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Does not render ProgramCompletion if program is an excluded type', async () => {
|
||||
setMetadata({
|
||||
related_programs: [{
|
||||
progress: {
|
||||
completed: 3,
|
||||
in_progress: 0,
|
||||
not_started: 0,
|
||||
},
|
||||
slug: 'excluded-program-type',
|
||||
title: 'Example Excluded Program',
|
||||
uuid: '123456',
|
||||
url: 'http://localhost:18000/dashboard/programs/123456',
|
||||
}],
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
|
||||
expect(screen.queryByTestId('program-completion')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('excluded-program-type')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Non-passing Experience', () => {
|
||||
it('Displays link to progress tab', async () => {
|
||||
setMetadata({ user_has_passing_grade: false });
|
||||
await fetchAndRender(<CourseNonPassing />);
|
||||
expect(screen.getByText('You’ve reached the end of the course!')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'View grades' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
61
src/courseware/course/course-exit/CourseNonPassing.jsx
Normal file
61
src/courseware/course/course-exit/CourseNonPassing.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Alert, Button } from '@edx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import DashboardFootnote from './DashboardFootnote';
|
||||
import messages from './messages';
|
||||
import { logClick, logVisit } from './utils';
|
||||
|
||||
function CourseNonPassing({ intl }) {
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
const { org, tabs } = useModel('courses', courseId);
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
// Get progress tab link for 'view grades' button
|
||||
const progressTab = tabs.find(tab => tab.slug === 'progress');
|
||||
const progressLink = progressTab && progressTab.url;
|
||||
|
||||
useEffect(() => logVisit(org, courseId, administrator, 'nonpassing'), [org, courseId, administrator]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{`${intl.formatMessage(messages.endOfCourseTitle)} | ${getConfig().SITE_NAME}`}</title>
|
||||
</Helmet>
|
||||
<div className="row w-100 mx-0 mb-4 px-5 py-4 border border-light justify-content-center">
|
||||
<div className="col-12 p-0 h2 text-center">
|
||||
{ intl.formatMessage(messages.endOfCourseHeader) }
|
||||
</div>
|
||||
<Alert variant="primary" className="col col-lg-10 mt-4 d-flex">
|
||||
<div className="row w-100 m-0 align-items-start">
|
||||
<div className="flex-grow-1 col-sm p-0">{ intl.formatMessage(messages.endOfCourseDescription) }</div>
|
||||
{progressLink && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-shrink-0 mt-3 mt-sm-0 mb-1 mb-sm-0 ml-2 ml-sm-5"
|
||||
href={progressLink}
|
||||
onClick={() => logClick(org, courseId, administrator, 'view_grades')}
|
||||
>
|
||||
{intl.formatMessage(messages.viewGradesButton)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
<DashboardFootnote />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CourseNonPassing.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseNonPassing);
|
||||
42
src/courseware/course/course-exit/DashboardFootnote.jsx
Normal file
42
src/courseware/course/course-exit/DashboardFootnote.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
FormattedMessage, injectIntl, intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import Footnote from './Footnote';
|
||||
import messages from './messages';
|
||||
|
||||
function DashboardFootnote({ intl }) {
|
||||
const dashboardLink = (
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().LMS_BASE_URL}/dashboard`}
|
||||
className="text-reset"
|
||||
>
|
||||
{intl.formatMessage(messages.dashboardLink)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
return (
|
||||
<Footnote
|
||||
icon={faCalendarAlt}
|
||||
text={(
|
||||
<FormattedMessage
|
||||
id="courseCelebration.dashboardInfo" // for historical reasons
|
||||
defaultMessage="You can access this course and its materials on your {dashboardLink}."
|
||||
values={{ dashboardLink }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
DashboardFootnote.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(DashboardFootnote);
|
||||
21
src/courseware/course/course-exit/Footnote.jsx
Normal file
21
src/courseware/course/course-exit/Footnote.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
function Footnote({ icon, text }) {
|
||||
return (
|
||||
<div className="row w-100 mx-0 my-4 justify-content-center">
|
||||
<p className="text-gray-700">
|
||||
<FontAwesomeIcon icon={icon} style={{ width: '20px' }} />
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Footnote.propTypes = {
|
||||
icon: PropTypes.shape({}).isRequired,
|
||||
text: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default Footnote;
|
||||
128
src/courseware/course/course-exit/ProgramCompletion.jsx
Normal file
128
src/courseware/course/course-exit/ProgramCompletion.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Button, Hyperlink } from '@edx/paragon';
|
||||
import microBachelorsCertImage from '../../../generic/assets/edX_microBachelors_certificate.png';
|
||||
import microMastersCertImage from '../../../generic/assets/edX_microMasters_certificate.png';
|
||||
import professionalCertImage from '../../../generic/assets/edX_professionalCertificate_certificate.png';
|
||||
import xSeriesCertImage from '../../../generic/assets/edX_xSeries_certificate.png';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* Note for Open edX developers:
|
||||
* There are pieces of this component that are hard-coded and specific to edX that may not apply to your organization.
|
||||
* This includes mentions of our edX program types (MicroMasters, MicroBachelors, Professional Certificate, and
|
||||
* XSeries), along with their respective support article URLs and image variable names.
|
||||
*
|
||||
* Currently, this component will not render unless the learner's completed course has a related program of one of the
|
||||
* four aforementioned types. This will not impact the parent components (i.e. CourseCelebration will render normally).
|
||||
*/
|
||||
|
||||
function ProgramCompletion({
|
||||
intl,
|
||||
progress,
|
||||
title,
|
||||
type,
|
||||
url,
|
||||
}) {
|
||||
if (progress.notStarted !== 0 || progress.inProgress !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let certImage;
|
||||
switch (type) {
|
||||
case 'microbachelors':
|
||||
certImage = microBachelorsCertImage;
|
||||
break;
|
||||
case 'micromasters':
|
||||
certImage = microMastersCertImage;
|
||||
break;
|
||||
case 'professional-certificate':
|
||||
certImage = professionalCertImage;
|
||||
break;
|
||||
case 'xseries':
|
||||
certImage = xSeriesCertImage;
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const programLink = (
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={url}
|
||||
className="text-reset"
|
||||
>
|
||||
{intl.formatMessage(messages.dashboardLink)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert variant="primary" className="row w-100 mx-0 my-3" data-testid="program-completion">
|
||||
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
|
||||
<div className="h4">{intl.formatMessage(messages.programsLastCourseHeader, { title })}</div>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="courseExit.programCompletion.dashboardMessage"
|
||||
defaultMessage="To view your certificate status, check the Programs section of your {programLink}."
|
||||
values={{ programLink }}
|
||||
/>
|
||||
</p>
|
||||
{type === 'microbachelors' && (
|
||||
<>
|
||||
<p>
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().SUPPORT_URL}/hc/en-us/articles/360004623154`}
|
||||
className="text-reset"
|
||||
>
|
||||
{intl.formatMessage(messages.microBachelorsLearnMore)}
|
||||
</Hyperlink>
|
||||
</p>
|
||||
<Button variant="primary" className="mb-2 mb-sm-0" href={`${getConfig().CREDENTIALS_BASE_URL}/records`}>
|
||||
{intl.formatMessage(messages.applyForCredit)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{type === 'micromasters' && (
|
||||
<p>
|
||||
{intl.formatMessage(messages.microMastersMessage)}
|
||||
{' '}
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().SUPPORT_URL}/hc/en-us/articles/360010346853-Does-a-Micromasters-certificate-count-towards-the-online-Master-s-degree-`}
|
||||
className="text-reset"
|
||||
>
|
||||
{intl.formatMessage(messages.microMastersLearnMore)}
|
||||
</Hyperlink>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-12 order-0 col-md-3 order-md-1 w-100 mb-3 p-0 text-center">
|
||||
<img
|
||||
src={certImage}
|
||||
alt={`${intl.formatMessage(messages.certificateImage)}`}
|
||||
className="w-100"
|
||||
style={{ maxWidth: '13rem' }}
|
||||
data-testid={type}
|
||||
/>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
ProgramCompletion.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
progress: PropTypes.shape({
|
||||
completed: PropTypes.number.isRequired,
|
||||
inProgress: PropTypes.number.isRequired,
|
||||
notStarted: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ProgramCompletion);
|
||||
65
src/courseware/course/course-exit/UpgradeFootnote.jsx
Normal file
65
src/courseware/course/course-exit/UpgradeFootnote.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
FormattedDate, FormattedMessage, injectIntl, intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
|
||||
import Footnote from './Footnote';
|
||||
import { logClick } from './utils';
|
||||
import messages from './messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
function UpgradeFootnote({ deadline, href, intl }) {
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
const { org } = useModel('courses', courseId);
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
const upgradeLink = (
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={href}
|
||||
className="text-reset"
|
||||
onClick={() => logClick(org, courseId, administrator, 'upgrade_footnote')}
|
||||
>
|
||||
{intl.formatMessage(messages.upgradeLink)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
const expirationDate = (
|
||||
<FormattedDate
|
||||
day="numeric"
|
||||
month="long"
|
||||
year="numeric"
|
||||
value={deadline}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Footnote
|
||||
icon={faCalendarAlt}
|
||||
text={(
|
||||
<FormattedMessage
|
||||
id="courseExit.upgradeFootnote"
|
||||
defaultMessage="Access to this course and its materials are available on your dashboard until {expirationDate}. To extend access, {upgradeLink}."
|
||||
values={{
|
||||
expirationDate,
|
||||
upgradeLink,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
UpgradeFootnote.propTypes = {
|
||||
deadline: PropTypes.instanceOf(Date).isRequired,
|
||||
href: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(UpgradeFootnote);
|
||||
BIN
src/courseware/course/course-exit/assets/celebration_456x328.gif
Normal file
BIN
src/courseware/course/course-exit/assets/celebration_456x328.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 469 KiB |
BIN
src/courseware/course/course-exit/assets/celebration_750x540.gif
Normal file
BIN
src/courseware/course/course-exit/assets/celebration_750x540.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 650 KiB |
4
src/courseware/course/course-exit/index.js
Normal file
4
src/courseware/course/course-exit/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import CourseExit from './CourseExit';
|
||||
import { getCourseExitText } from './utils';
|
||||
|
||||
export { CourseExit, getCourseExitText };
|
||||
163
src/courseware/course/course-exit/messages.js
Normal file
163
src/courseware/course/course-exit/messages.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
applyForCredit: {
|
||||
id: 'courseExit.programs.applyForCredit',
|
||||
defaultMessage: 'Apply for credit',
|
||||
description: 'Button for the learner to apply for course credit',
|
||||
},
|
||||
certificateHeaderDownloadable: {
|
||||
id: 'courseCelebration.certificateHeader.downloadable',
|
||||
defaultMessage: 'Your certificate is available!',
|
||||
description: 'Text displayed when course certificate is ready to be downloaded',
|
||||
},
|
||||
certificateHeaderNotAvailable: {
|
||||
id: 'courseCelebration.certificateHeader.notAvailable',
|
||||
defaultMessage: 'Your certificate will be available soon!',
|
||||
description: 'Text displayed when course certificate is not yet available to be viewed',
|
||||
},
|
||||
certificateHeaderUnverified: {
|
||||
id: 'courseCelebration.certificateHeader.unverified',
|
||||
defaultMessage: 'You must complete verification to receive your certificate.',
|
||||
description: 'Text displayed when a user has not verified their identity and cannot view their course certificate',
|
||||
},
|
||||
certificateHeaderRequestable: {
|
||||
id: 'courseCelebration.certificateHeader.requestable',
|
||||
defaultMessage: 'Congratulations, you qualified for a certificate!',
|
||||
description: 'Text displayed when a user has completed the course and can request a certificate',
|
||||
},
|
||||
certificateHeaderUpgradable: {
|
||||
id: 'courseCelebration.certificateHeader.upgradable',
|
||||
defaultMessage: 'Upgrade to pursue a verified certificate',
|
||||
},
|
||||
certificateImage: {
|
||||
id: 'courseCelebration.certificateImage',
|
||||
defaultMessage: 'Sample certificate',
|
||||
description: 'Alt text used to describe an image of a certificate',
|
||||
},
|
||||
congratulationsHeader: {
|
||||
id: 'courseCelebration.congratulationsHeader',
|
||||
defaultMessage: 'Congratulations!',
|
||||
},
|
||||
congratulationsImage: {
|
||||
id: 'courseCelebration.congratulationsImage',
|
||||
defaultMessage: 'Four people raising their hands in celebration',
|
||||
description: 'Alt text used to describe celebratory image',
|
||||
},
|
||||
dashboardLink: {
|
||||
id: 'courseExit.dashboardLink',
|
||||
defaultMessage: 'Dashboard',
|
||||
description: 'Link to user’s dashboard',
|
||||
},
|
||||
downloadButton: {
|
||||
id: 'courseCelebration.downloadButton',
|
||||
defaultMessage: 'Download my certificate',
|
||||
description: 'Button to download the course certificate',
|
||||
},
|
||||
endOfCourseDescription: {
|
||||
id: 'courseExit.endOfCourseDescription',
|
||||
defaultMessage: 'Unfortunately, you are not currently eligible for a certificate. You need to receive a passing grade to be eligible for a certificate.',
|
||||
},
|
||||
endOfCourseHeader: {
|
||||
id: 'courseExit.endOfCourseHeader',
|
||||
defaultMessage: 'You’ve reached the end of the course!',
|
||||
},
|
||||
endOfCourseTitle: {
|
||||
id: 'courseExit.endOfCourseTitle',
|
||||
defaultMessage: 'End of Course',
|
||||
},
|
||||
idVerificationSupportLink: {
|
||||
id: 'courseExit.idVerificationSupportLink',
|
||||
defaultMessage: 'Learn more about ID verification',
|
||||
description: 'Link to an article about identity verification',
|
||||
},
|
||||
linkedinAddToProfileButton: {
|
||||
id: 'courseCelebration.linkedinAddToProfileButton',
|
||||
defaultMessage: 'Add to LinkedIn profile',
|
||||
description: 'Button to add certificate information to the user’s LinkedIn profile',
|
||||
},
|
||||
microBachelorsLearnMore: {
|
||||
id: 'courseExit.programs.microBachelors.learnMore',
|
||||
defaultMessage: 'Learn more about how your MicroBachelors credential can be applied for credit.',
|
||||
},
|
||||
microMastersLearnMore: {
|
||||
id: 'courseExit.programs.microMasters.learnMore',
|
||||
defaultMessage: 'Learn more about the process of applying MicroMasters certificates to Master’s degrees.',
|
||||
},
|
||||
microMastersMessage: {
|
||||
id: 'courseExit.programs.microMasters.mastersMessage',
|
||||
defaultMessage: 'If you’re interested in using your MicroMasters certificate towards a Master’s program, you can get started today!',
|
||||
},
|
||||
nextButtonComplete: {
|
||||
id: 'learn.sequence.navigation.complete.button', // for historical reasons
|
||||
defaultMessage: 'Complete the course',
|
||||
},
|
||||
nextButtonEnd: {
|
||||
id: 'courseExit.nextButton.endOfCourse',
|
||||
defaultMessage: 'Next (end of course)',
|
||||
},
|
||||
profileLink: {
|
||||
id: 'courseExit.profileLink',
|
||||
defaultMessage: 'Profile',
|
||||
description: 'Link to user’s profile',
|
||||
},
|
||||
programsLastCourseHeader: {
|
||||
id: 'courseExit.programs.lastCourse',
|
||||
defaultMessage: 'You have completed the last course in {title}!',
|
||||
},
|
||||
requestCertificateBodyText: {
|
||||
id: 'courseCelebration.requestCertificateBodyText',
|
||||
defaultMessage: 'In order to access your certificate, request it below.',
|
||||
},
|
||||
requestCertificateButton: {
|
||||
id: 'courseCelebration.requestCertificateButton',
|
||||
defaultMessage: 'Request certificate',
|
||||
description: 'Button to request the course certificate',
|
||||
},
|
||||
shareHeader: {
|
||||
id: 'courseCelebration.shareHeader',
|
||||
defaultMessage: 'You have completed your course. Share your success on social media or email.',
|
||||
},
|
||||
socialMessage: {
|
||||
id: 'courseExit.social.shareCompletionMessage',
|
||||
defaultMessage: 'I just completed {title} with {platform}!',
|
||||
description: 'Shown when sharing course progress on a social network',
|
||||
},
|
||||
upgradeButton: {
|
||||
id: 'courseExit.upgradeButton',
|
||||
defaultMessage: 'Upgrade now',
|
||||
},
|
||||
upgradeLink: {
|
||||
id: 'courseExit.upgradeLink',
|
||||
defaultMessage: 'upgrade now',
|
||||
},
|
||||
verificationPending: {
|
||||
id: 'courseCelebration.verificationPending',
|
||||
defaultMessage: 'Your ID verification is pending and your certificate will be available once approved.',
|
||||
},
|
||||
verifiedCertificateSupportLink: {
|
||||
id: 'courseExit.verifiedCertificateSupportLink',
|
||||
defaultMessage: 'Learn more about verified certificates',
|
||||
},
|
||||
verifyIdentityButton: {
|
||||
id: 'courseCelebration.verifyIdentityButton',
|
||||
defaultMessage: 'Verify ID now',
|
||||
description: 'Button to verify the identify of the user',
|
||||
},
|
||||
viewCertificateButton: {
|
||||
id: 'courseCelebration.viewCertificateButton',
|
||||
defaultMessage: 'View my certificate',
|
||||
description: 'Button to view the course certificate',
|
||||
},
|
||||
viewCoursesButton: {
|
||||
id: 'courseExit.viewCoursesButton',
|
||||
defaultMessage: 'View my courses',
|
||||
description: 'Button to redirect user to their course dashboard',
|
||||
},
|
||||
viewGradesButton: {
|
||||
id: 'courseExit.viewGradesButton',
|
||||
defaultMessage: 'View grades',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
109
src/courseware/course/course-exit/utils.js
Normal file
109
src/courseware/course/course-exit/utils.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const COURSE_EXIT_MODES = {
|
||||
disabled: 0,
|
||||
celebration: 1,
|
||||
nonPassing: 2,
|
||||
};
|
||||
|
||||
// These are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
|
||||
const CELEBRATION_STATUSES = [
|
||||
'audit_passing',
|
||||
'downloadable',
|
||||
'earned_but_not_available',
|
||||
'honor_passing',
|
||||
'requesting',
|
||||
'unverified',
|
||||
];
|
||||
const NON_CERTIFICATE_STATUSES = [ // no certificate will be given, though a valid certificateData block is provided
|
||||
'audit_passing',
|
||||
'honor_passing', // provided when honor is configured to not give a certificate
|
||||
];
|
||||
|
||||
function getCourseExitMode(courseId) {
|
||||
const {
|
||||
certificateData,
|
||||
courseExitPageIsActive,
|
||||
userHasPassingGrade,
|
||||
} = useModel('courses', courseId);
|
||||
|
||||
if (!courseExitPageIsActive) {
|
||||
return COURSE_EXIT_MODES.disabled;
|
||||
}
|
||||
|
||||
// Set defaults for our status-calculated variables, used when no certificateData is provided.
|
||||
// This happens when `get_cert_data` in edx-platform returns None, which it does if we are
|
||||
// in a certificate-earning mode, but the certificate is not available (maybe they didn't pass
|
||||
// or course is not set up for certificates or something). Audit users will always have a
|
||||
// certificateData sent over.
|
||||
let isCelebratoryStatus = true;
|
||||
let isEligibleForCertificate = true;
|
||||
|
||||
if (certificateData) {
|
||||
const { certStatus } = certificateData;
|
||||
isCelebratoryStatus = CELEBRATION_STATUSES.indexOf(certStatus) !== -1;
|
||||
isEligibleForCertificate = NON_CERTIFICATE_STATUSES.indexOf(certStatus) === -1;
|
||||
}
|
||||
|
||||
if (isEligibleForCertificate && !userHasPassingGrade) {
|
||||
return COURSE_EXIT_MODES.nonPassing;
|
||||
}
|
||||
if (isCelebratoryStatus) {
|
||||
return COURSE_EXIT_MODES.celebration;
|
||||
}
|
||||
return COURSE_EXIT_MODES.disabled;
|
||||
}
|
||||
|
||||
// Returns null if course exit is either not active or not handling the current case
|
||||
function getCourseExitText(courseId, intl) {
|
||||
switch (getCourseExitMode(courseId)) {
|
||||
case COURSE_EXIT_MODES.celebration:
|
||||
return intl.formatMessage(messages.nextButtonComplete);
|
||||
case COURSE_EXIT_MODES.nonPassing:
|
||||
return intl.formatMessage(messages.nextButtonEnd);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Meant to be used as part of a button's onClick handler.
|
||||
// For convenience, you can pass a falsy event and it will be ignored.
|
||||
const logClick = (org, courseId, administrator, event) => {
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendTrackEvent(`edx.ui.lms.course_exit.${event}.clicked`, {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
});
|
||||
};
|
||||
|
||||
// Use like the following to call this only once on initial page load:
|
||||
// useEffect(() => logVisit(org, courseId, administrator, variant), [org, courseId, administrator, variant]);
|
||||
// For convenience, you can pass a falsy variant and it will be ignored.
|
||||
const logVisit = (org, courseId, administrator, variant) => {
|
||||
if (!variant) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendTrackEvent('edx.ui.lms.course_exit.visited', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
variant,
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
COURSE_EXIT_MODES,
|
||||
getCourseExitMode,
|
||||
getCourseExitText,
|
||||
logClick,
|
||||
logVisit,
|
||||
};
|
||||
@@ -108,17 +108,15 @@ function CourseLicense({
|
||||
intl,
|
||||
}) {
|
||||
const renderAllRightsReservedLicense = () => (
|
||||
<div>
|
||||
<div className="text-gray-500">
|
||||
<FontAwesomeIcon aria-hidden="true" className="mr-1" icon={faCopyright} />
|
||||
<span className="license-text">
|
||||
{intl.formatMessage(messages['learn.course.license.allRightsReserved.text'])}
|
||||
</span>
|
||||
{intl.formatMessage(messages['learn.course.license.allRightsReserved.text'])}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCreativeCommonsLicense = (activeCreativeCommonsLicenseTags, version) => (
|
||||
<a
|
||||
className="text-decoration-none text-gray-700"
|
||||
className="text-decoration-none text-gray-500"
|
||||
rel="license noopener noreferrer"
|
||||
target="_blank"
|
||||
href={`https://creativecommons.org/licenses/${activeCreativeCommonsLicenseTags.join('-')}/${version}/`}
|
||||
@@ -135,16 +133,14 @@ function CourseLicense({
|
||||
<FontAwesomeIcon aria-hidden="true" className="mr-1" icon={CreativeCommonsLicenseTags[tag].icon} />
|
||||
</span>
|
||||
))}
|
||||
<span className="license-text">
|
||||
{intl.formatMessage(messages['learn.course.license.creativeCommons.text'])}
|
||||
</span>
|
||||
{intl.formatMessage(messages['learn.course.license.creativeCommons.text'])}
|
||||
</a>
|
||||
);
|
||||
|
||||
const [licenseType, licenseOptions, licenseVersion] = parseLicense(license);
|
||||
|
||||
return (
|
||||
<div className="course-license text-right small">
|
||||
<div className="text-right small py-1">
|
||||
{licenseType === 'all-rights-reserved' && renderAllRightsReservedLicense()}
|
||||
{licenseType === 'creative-commons' && renderCreativeCommonsLicense(
|
||||
Object.keys(licenseOptions),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
@@ -3,9 +3,13 @@ import React, {
|
||||
useEffect, useContext, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
sendTrackEvent,
|
||||
sendTrackingLogEvent,
|
||||
} from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
|
||||
import PageLoading from '../../../generic/PageLoading';
|
||||
import { UserMessagesContext, ALERT_TYPES } from '../../../generic/user-messages';
|
||||
@@ -24,6 +28,8 @@ function Sequence({
|
||||
nextSequenceHandler,
|
||||
previousSequenceHandler,
|
||||
intl,
|
||||
isREV1512FlyoverVisible, /* This line should be reverted after the REV1512 experiment */
|
||||
REV1512FlyoverEnabled, /* This line should be reverted after the REV1512 experiment */
|
||||
}) {
|
||||
const course = useModel('courses', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
@@ -68,6 +74,7 @@ function Sequence({
|
||||
payload.target_tab = targetIndex + 1;
|
||||
}
|
||||
sendTrackEvent(eventName, payload);
|
||||
sendTrackingLogEvent(eventName, payload);
|
||||
};
|
||||
|
||||
const { add, remove } = useContext(UserMessagesContext);
|
||||
@@ -112,38 +119,57 @@ function Sequence({
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
TODO: When the micro-frontend supports viewing special exams without redirecting to the legacy
|
||||
experience, we can remove this whole conditional. For now, though, we show the spinner here
|
||||
because we expect CoursewareContainer to be performing a redirect to the legacy experience while
|
||||
we're waiting. That redirect may take a few seconds, so we show the spinner in the meantime.
|
||||
*/
|
||||
if (sequenceStatus === 'loaded' && sequence.isTimeLimited) {
|
||||
return (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
|
||||
const goToCourseExitPage = () => {
|
||||
history.push(`/course/${courseId}/course-end`);
|
||||
};
|
||||
|
||||
if (sequenceStatus === 'loaded') {
|
||||
return (
|
||||
<div className="sequence-container">
|
||||
<div className="sequence">
|
||||
<SequenceNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
className="mb-4"
|
||||
nextSequenceHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||
handleNext();
|
||||
}}
|
||||
onNavigate={(destinationUnitId) => {
|
||||
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
||||
handleNavigate(destinationUnitId);
|
||||
}}
|
||||
previousSequenceHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
||||
handlePrevious();
|
||||
}}
|
||||
/>
|
||||
<div className="unit-container flex-grow-1">
|
||||
<SequenceContent
|
||||
courseId={courseId}
|
||||
gated={gated}
|
||||
<div>
|
||||
<div className="sequence-container" style={{ display: 'inline-flex', flexDirection: 'row' }}>
|
||||
<div className="sequence" style={{ width: '100%' }}>
|
||||
<SequenceNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
unitLoadedHandler={handleUnitLoaded}
|
||||
className="mb-4"
|
||||
nextSequenceHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||
handleNext();
|
||||
}}
|
||||
onNavigate={(destinationUnitId) => {
|
||||
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
||||
handleNavigate(destinationUnitId);
|
||||
}}
|
||||
previousSequenceHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
||||
handlePrevious();
|
||||
}}
|
||||
goToCourseExitPage={() => goToCourseExitPage()}
|
||||
/>
|
||||
{unitHasLoaded && (
|
||||
<div className="unit-container flex-grow-1">
|
||||
<SequenceContent
|
||||
courseId={courseId}
|
||||
gated={gated}
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
unitLoadedHandler={handleUnitLoaded}
|
||||
/>
|
||||
{unitHasLoaded && (
|
||||
<UnitNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
@@ -155,13 +181,27 @@ function Sequence({
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
||||
handleNext();
|
||||
}}
|
||||
goToCourseExitPage={() => goToCourseExitPage()}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* This block of code should be reverted post REV1512 experiment */}
|
||||
{REV1512FlyoverEnabled && isREV1512FlyoverVisible() && (
|
||||
<div
|
||||
className="rev-1512-box"
|
||||
style={{
|
||||
border: 'solid 1px #e1dddb',
|
||||
height: '393px',
|
||||
width: '330px',
|
||||
verticalAlign: 'top',
|
||||
marginLeft: '20px',
|
||||
padding: '20px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="sequence-footer px-4 py-1">
|
||||
<CourseLicense license={course.license || undefined} />
|
||||
</div>
|
||||
<CourseLicense license={course.license || undefined} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -182,6 +222,8 @@ Sequence.propTypes = {
|
||||
nextSequenceHandler: PropTypes.func.isRequired,
|
||||
previousSequenceHandler: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
isREV1512FlyoverVisible: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
|
||||
REV1512FlyoverEnabled: PropTypes.bool.isRequired, /* This line should be reverted after the REV1512 experiment */
|
||||
};
|
||||
|
||||
Sequence.defaultProps = {
|
||||
|
||||
@@ -78,6 +78,37 @@ describe('Sequence', () => {
|
||||
expect(screen.queryByText('Loading locked content messaging...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correctly for exam content', async () => {
|
||||
// Exams should NOT render in the Sequence. They should permanently show a spinner until the
|
||||
// application redirects away from the page. Note that this component is not responsible for
|
||||
// that redirect behavior, so there's no record of it here.
|
||||
// See CoursewareContainer.jsx "checkExamRedirect" function.
|
||||
const sequenceBlock = [Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential', children: [unitBlocks.map(block => block.id)] },
|
||||
{ courseId: courseMetadata.id },
|
||||
)];
|
||||
const sequenceMetadata = [Factory.build(
|
||||
'sequenceMetadata',
|
||||
{ is_time_limited: true },
|
||||
{ courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlock[0] },
|
||||
)];
|
||||
const testStore = await initializeTestStore(
|
||||
{
|
||||
courseMetadata, unitBlocks, sequenceBlock, sequenceMetadata,
|
||||
}, false,
|
||||
);
|
||||
const { container } = render(
|
||||
<Sequence {...mockData} {...{ sequenceId: sequenceBlock[0].id }} />,
|
||||
{ store: testStore },
|
||||
);
|
||||
|
||||
// We expect that the sequence container isn't rendering at all.
|
||||
expect(container.querySelector('.sequence-container')).toBeNull();
|
||||
// But that we're seeing a nice spinner.
|
||||
expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays error message on sequence load failure', async () => {
|
||||
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
|
||||
testStore.dispatch(fetchSequenceFailure({ sequenceId: mockData.sequenceId }));
|
||||
|
||||
@@ -31,7 +31,7 @@ function SequenceContent({
|
||||
<ContentLock
|
||||
courseId={courseId}
|
||||
sequenceTitle={sequence.title}
|
||||
prereqSectionName={sequence.gatedContent.gatedSectionName}
|
||||
prereqSectionName={sequence.gatedContent.prereqSectionName}
|
||||
prereqId={sequence.gatedContent.prereqId}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -50,6 +50,7 @@ function SequenceContent({
|
||||
return (
|
||||
<Unit
|
||||
courseId={courseId}
|
||||
format={sequence.format}
|
||||
key={unitId}
|
||||
id={unitId}
|
||||
onLoaded={unitLoadedHandler}
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('Sequence Content', () => {
|
||||
expect(screen.queryByText('Loading locked content messaging...')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('svg')).toHaveClass('fa-lock');
|
||||
expect(screen.getByText(
|
||||
`You must complete the prerequisite: '${gatedContent.gatedSectionName}' to access this content.`,
|
||||
`You must complete the prerequisite: '${gatedContent.prereqSectionName}' to access this content.`,
|
||||
)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Go To Prerequisite Section' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user