Compare commits
409 Commits
open-relea
...
release/ul
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d5484ff1d | ||
|
|
52692dc662 | ||
|
|
f91af211f6 | ||
|
|
7318fb3ef7 | ||
|
|
7233f08d3d | ||
|
|
d6d229f1c3 | ||
|
|
47b9a436a6 | ||
|
|
e556d5b74c | ||
|
|
694d95a816 | ||
|
|
e83813da8e | ||
|
|
a54a1b8c3c | ||
|
|
d3188efbcc | ||
|
|
33f737579a | ||
|
|
870263001e | ||
|
|
af50d5a6ed | ||
|
|
7fccf7794c | ||
|
|
c760bc479b | ||
|
|
d5140a6bf0 | ||
|
|
9bf5d01c41 | ||
|
|
f3334085d7 | ||
|
|
4840fff44b | ||
|
|
579bd0365b | ||
|
|
2b4a9661a5 | ||
|
|
a6e4e28e58 | ||
|
|
e6f7588ccd | ||
|
|
db29e314c3 | ||
|
|
e9121f9261 | ||
|
|
9cbc2276d6 | ||
|
|
4c8aa7c80c | ||
|
|
68926334a1 | ||
|
|
56a73eee15 | ||
|
|
bf95916063 | ||
|
|
48270c35dd | ||
|
|
33d7d669d9 | ||
|
|
a75c89cd14 | ||
|
|
06902d8ae8 | ||
|
|
e4134641e6 | ||
|
|
77fcc83efd | ||
|
|
b0505352be | ||
|
|
ddbc2124ef | ||
|
|
462e75f6a6 | ||
|
|
bc4c8c2dec | ||
|
|
ecd5164806 | ||
|
|
44d952bef7 | ||
|
|
7eddc918bb | ||
|
|
f28528e813 | ||
|
|
ab3f5fd7bc | ||
|
|
73eaf61261 | ||
|
|
db9663b664 | ||
|
|
7edac93752 | ||
|
|
d1dede568e | ||
|
|
31b02d777f | ||
|
|
67bb54a028 | ||
|
|
847d4e5ce6 | ||
|
|
b89cdb4a69 | ||
|
|
a1d0afff6c | ||
|
|
1714f285b0 | ||
|
|
03cda5326a | ||
|
|
a71152b008 | ||
|
|
d14c2a9ffd | ||
|
|
b6c29df0a0 | ||
|
|
2ce833341b | ||
|
|
ff57a6b217 | ||
|
|
dc6ee749be | ||
|
|
236fb57023 | ||
|
|
d3d2f75c12 | ||
|
|
8e9306d35a | ||
|
|
b1ee8a3713 | ||
|
|
73406fbb31 | ||
|
|
f4ae1c51ff | ||
|
|
7ef3892027 | ||
|
|
1484bc50f7 | ||
|
|
6b197aad27 | ||
|
|
1412bfe209 | ||
|
|
e8d3bd7c24 | ||
|
|
511091055b | ||
|
|
24c9437e91 | ||
|
|
fb6f110732 | ||
|
|
1656b73a31 | ||
|
|
81671ad328 | ||
|
|
4cc716b20c | ||
|
|
756fbbac83 | ||
|
|
903fe28ff6 | ||
|
|
14c662dc53 | ||
|
|
af432eab27 | ||
|
|
dde640df33 | ||
|
|
b827db800d | ||
|
|
5b7f76b43d | ||
|
|
cf4bea3604 | ||
|
|
85e6e9266d | ||
|
|
360af1f0e9 | ||
|
|
26f4a90976 | ||
|
|
0d45c78ace | ||
|
|
c18214dc41 | ||
|
|
54611c1b4d | ||
|
|
7ca4b71ff7 | ||
|
|
63a7ff83cf | ||
|
|
8ecaa018da | ||
|
|
64ca156095 | ||
|
|
c06f2c37ab | ||
|
|
d5a092b220 | ||
|
|
81b621195e | ||
|
|
226c4cc1d7 | ||
|
|
7f6a59b701 | ||
|
|
7ea0bd175b | ||
|
|
dae1d63e23 | ||
|
|
6ab0deb7b7 | ||
|
|
e5f04d92b9 | ||
|
|
f39a50e7dc | ||
|
|
72724bcafb | ||
|
|
964abbe0c3 | ||
|
|
96d20e20e6 | ||
|
|
a56fd7d0e1 | ||
|
|
679caa61f3 | ||
|
|
420060967b | ||
|
|
91d3762513 | ||
|
|
2e3ed087d1 | ||
|
|
d76d4db097 | ||
|
|
04b314d157 | ||
|
|
1db4848d1a | ||
|
|
8f294781d2 | ||
|
|
d6908abb13 | ||
|
|
96d3d0da7e | ||
|
|
14cc32fcf6 | ||
|
|
0ac127e4c9 | ||
|
|
06e5fb5a44 | ||
|
|
2235737490 | ||
|
|
fca32ae872 | ||
|
|
ae04e5b366 | ||
|
|
db3f1b9cb0 | ||
|
|
ec360bc545 | ||
|
|
6c5220b4d7 | ||
|
|
f433118a8d | ||
|
|
e798331855 | ||
|
|
adb5796ff6 | ||
|
|
9958638a86 | ||
|
|
b8e844eba6 | ||
|
|
da633ffbd9 | ||
|
|
46889c2aba | ||
|
|
3cbbb0272b | ||
|
|
911c7658f5 | ||
|
|
b54d1e467e | ||
|
|
e34d18d727 | ||
|
|
6949e5708f | ||
|
|
eef6b1efe2 | ||
|
|
9dc45e192d | ||
|
|
bd9c97c269 | ||
|
|
c70fb138f0 | ||
|
|
8823cfaa0a | ||
|
|
7865fadec2 | ||
|
|
5be1620f1d | ||
|
|
d5a6a59d07 | ||
|
|
826f1382dd | ||
|
|
5e5fdeba44 | ||
|
|
01369eb00d | ||
|
|
4bb4bb7a88 | ||
|
|
1d154f46c1 | ||
|
|
420db8133f | ||
|
|
1ffc93dc6d | ||
|
|
346e15abd4 | ||
|
|
4726c23bc3 | ||
|
|
cbbb417894 | ||
|
|
c57f28ad40 | ||
|
|
310fb84517 | ||
|
|
623f6946e5 | ||
|
|
cf124877e8 | ||
|
|
0456ad9318 | ||
|
|
7be87b0f83 | ||
|
|
cbe5b28762 | ||
|
|
4a80532b8d | ||
|
|
e505f78cfb | ||
|
|
3811f5f9d5 | ||
|
|
8a20b908c7 | ||
|
|
8a6fa937ea | ||
|
|
dafdcad2b4 | ||
|
|
cd56ffaf9d | ||
|
|
c11cb85d78 | ||
|
|
b09bcbd3ae | ||
|
|
4a925f9c11 | ||
|
|
f5b6243c61 | ||
|
|
98c670afe7 | ||
|
|
038b05ba6c | ||
|
|
020e7fb42c | ||
|
|
ead98538b9 | ||
|
|
90ef6ace5c | ||
|
|
e0196f2a2a | ||
|
|
b7befcff7e | ||
|
|
642031bf87 | ||
|
|
f778f27647 | ||
|
|
b3bce8713c | ||
|
|
dacb30c73e | ||
|
|
81a4deeec0 | ||
|
|
9a1b05a1a4 | ||
|
|
b9e1fb0d2b | ||
|
|
ebd0f8816c | ||
|
|
d749429361 | ||
|
|
19b8df35ae | ||
|
|
e468d2087b | ||
|
|
42e0ac86d7 | ||
|
|
ea5cf37fd8 | ||
|
|
e4cdec7389 | ||
|
|
8aafc6b8bd | ||
|
|
913c8e4086 | ||
|
|
c221770213 | ||
|
|
4fe40c264f | ||
|
|
c20c7677a3 | ||
|
|
2ff8c3949e | ||
|
|
4a5c43d365 | ||
|
|
4da37f369b | ||
|
|
0effb32318 | ||
|
|
6813872dd3 | ||
|
|
e337a367d1 | ||
|
|
65343470e1 | ||
|
|
e69114a839 | ||
|
|
2d63a14c2e | ||
|
|
2d1f893a40 | ||
|
|
64f92deeb1 | ||
|
|
d47433ee83 | ||
|
|
6f1159617e | ||
|
|
8cc6b8cdde | ||
|
|
048488fb25 | ||
|
|
2a58ad2477 | ||
|
|
798c51b4e7 | ||
|
|
9a83d67d78 | ||
|
|
356b183c5c | ||
|
|
d64a4e448b | ||
|
|
860b3f9952 | ||
|
|
4418c5422f | ||
|
|
372c9de1db | ||
|
|
65adaf18d4 | ||
|
|
ed77465282 | ||
|
|
f5f6747ecb | ||
|
|
fbe16483ac | ||
|
|
e4a0105042 | ||
|
|
1d19ae0e7b | ||
|
|
b9d11982e3 | ||
|
|
73590f1ccd | ||
|
|
f8d35bf45d | ||
|
|
2038bad822 | ||
|
|
6c11947397 | ||
|
|
26565cd89c | ||
|
|
3a203e8351 | ||
|
|
500e4abcb9 | ||
|
|
d78851bb5b | ||
|
|
8c9a43d02b | ||
|
|
13c7c1de89 | ||
|
|
ba44b28cec | ||
|
|
79affe0629 | ||
|
|
d0ec7e3fb2 | ||
|
|
3f8b8077a9 | ||
|
|
290f17d76d | ||
|
|
64a1149550 | ||
|
|
e907ade40a | ||
|
|
68a7bf5527 | ||
|
|
db75ea28e4 | ||
|
|
ba4bdfe6af | ||
|
|
b63508db97 | ||
|
|
82b27e59cc | ||
|
|
ec8b5c5d6e | ||
|
|
dc1e9cd2e8 | ||
|
|
a681333a08 | ||
|
|
7cbbc720d1 | ||
|
|
863a838e6e | ||
|
|
5b046e828a | ||
|
|
a8f72c5e75 | ||
|
|
bb6c678904 | ||
|
|
71c2a31531 | ||
|
|
99a44dda37 | ||
|
|
5ae86465cc | ||
|
|
6e9c105eb9 | ||
|
|
26199fa954 | ||
|
|
3a542766d7 | ||
|
|
bbe03dc46f | ||
|
|
167d51b596 | ||
|
|
7efe8f5cc3 | ||
|
|
263fe6d1a2 | ||
|
|
76f98d5bb2 | ||
|
|
e0386fe40b | ||
|
|
7d99677acd | ||
|
|
29bc2d9e17 | ||
|
|
27f3e79508 | ||
|
|
ed74bee760 | ||
|
|
c7a81fe07a | ||
|
|
d880aac569 | ||
|
|
072d608c64 | ||
|
|
9437142bc8 | ||
|
|
58c8ec5777 | ||
|
|
07357b9f10 | ||
|
|
1264b4245c | ||
|
|
e3ecee18e3 | ||
|
|
c3d96622e8 | ||
|
|
e577efbd27 | ||
|
|
df361236d0 | ||
|
|
e656f5445c | ||
|
|
f124c0d491 | ||
|
|
cc041ba348 | ||
|
|
257c9dcd7f | ||
|
|
1857b86c7e | ||
|
|
1c3610e9af | ||
|
|
796bbef10b | ||
|
|
799e57f970 | ||
|
|
cf3a91dde0 | ||
|
|
72381a783b | ||
|
|
75f56ea4bd | ||
|
|
a418ba6adb | ||
|
|
2da930f819 | ||
|
|
a2c38112fb | ||
|
|
36535d188d | ||
|
|
79f49032e3 | ||
|
|
9b3b123e45 | ||
|
|
98436b4605 | ||
|
|
7652fa46d1 | ||
|
|
2347ce88cd | ||
|
|
78e5c57bd3 | ||
|
|
108636761c | ||
|
|
5f56828bda | ||
|
|
23e522e893 | ||
|
|
9423a889ba | ||
|
|
85b95adb9f | ||
|
|
d4e7b416b1 | ||
|
|
0cb369af2b | ||
|
|
c908d3a3dd | ||
|
|
895a3b6b9f | ||
|
|
36b3c36379 | ||
|
|
3917262492 | ||
|
|
7ad120c4d5 | ||
|
|
94d20833aa | ||
|
|
4698b11ab9 | ||
|
|
e46d4cc54d | ||
|
|
92d8f637c0 | ||
|
|
c3b786c771 | ||
|
|
00aab61551 | ||
|
|
b547ada1a5 | ||
|
|
b7159590d3 | ||
|
|
a2d7f704a6 | ||
|
|
bca3aaccf5 | ||
|
|
3b46df6d03 | ||
|
|
7c8e26d213 | ||
|
|
b1c2c1f7b3 | ||
|
|
4797425a21 | ||
|
|
54bb72b60c | ||
|
|
a91ef116f1 | ||
|
|
80634c6188 | ||
|
|
ec1bad25a7 | ||
|
|
b2980f02a0 | ||
|
|
9d38e9dc93 | ||
|
|
c28991d6e0 | ||
|
|
ff7366d42a | ||
|
|
6def66677a | ||
|
|
8335dec0de | ||
|
|
992ab4887b | ||
|
|
5d2aa5e60a | ||
|
|
9dd79ca77e | ||
|
|
9bfa0d06b6 | ||
|
|
c6d4bb3b45 | ||
|
|
db1fc7782c | ||
|
|
efd57c326e | ||
|
|
a18ed8ed6c | ||
|
|
a340381738 | ||
|
|
170cbe1da0 | ||
|
|
174de4bc1b | ||
|
|
55e2332b00 | ||
|
|
25d746b7c8 | ||
|
|
e790e2636f | ||
|
|
c428222125 | ||
|
|
58365ba18e | ||
|
|
ab87167052 | ||
|
|
4928f505bd | ||
|
|
59a68afa5d | ||
|
|
486faa5744 | ||
|
|
023f5ac254 | ||
|
|
4dc4725ba1 | ||
|
|
600f5b4dff | ||
|
|
06d79ce16d | ||
|
|
b3a06fd07e | ||
|
|
684ac495f5 | ||
|
|
79575330c9 | ||
|
|
2bf326fc67 | ||
|
|
e004ead21d | ||
|
|
4d723335bf | ||
|
|
d065c23e32 | ||
|
|
83c600737b | ||
|
|
a30dccd3cf | ||
|
|
4e1346716b | ||
|
|
5906576c6e | ||
|
|
d7cffcd414 | ||
|
|
cf49d92951 | ||
|
|
2fd5e92d2d | ||
|
|
bce25c462a | ||
|
|
fe773d1700 | ||
|
|
748e73d128 | ||
|
|
c38d69f9db | ||
|
|
18103bcf54 | ||
|
|
9698c4d4de | ||
|
|
a70a26f2e5 | ||
|
|
308f03cf3a | ||
|
|
9a0cdc06c9 | ||
|
|
a64f0e0406 | ||
|
|
ce24a58c99 | ||
|
|
c257048d29 | ||
|
|
7c9211073f | ||
|
|
040f1cb55b | ||
|
|
00e7680c20 | ||
|
|
cbb419c256 | ||
|
|
12205de132 | ||
|
|
62465ec956 | ||
|
|
165097d061 | ||
|
|
570cdb4b2a | ||
|
|
391ea08b20 |
7
.env
7
.env
@@ -4,6 +4,7 @@
|
||||
NODE_ENV='production'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
APP_ID='learning'
|
||||
BASE_URL=''
|
||||
CONTACT_URL=''
|
||||
CREDENTIALS_BASE_URL=''
|
||||
@@ -11,10 +12,12 @@ CREDIT_HELP_LINK_URL=''
|
||||
CSRF_TOKEN_API_PATH=''
|
||||
DISCOVERY_API_BASE_URL=''
|
||||
DISCUSSIONS_MFE_BASE_URL=''
|
||||
DISCOUNT_CODE_INFO_URL=''
|
||||
ECOMMERCE_BASE_URL=''
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
|
||||
ENTERPRISE_LEARNER_PORTAL_URL=''
|
||||
EXAMS_BASE_URL=''
|
||||
FAVICON_URL=''
|
||||
IGNORED_ERROR_REGEX=''
|
||||
@@ -47,3 +50,7 @@ TWITTER_HASHTAG=''
|
||||
TWITTER_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
FEATURE_ENABLE_CHAT_V2_ENDPOINT=''
|
||||
|
||||
@@ -4,17 +4,20 @@
|
||||
NODE_ENV='development'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
APP_ID='learning'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
|
||||
CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-college-credit-or-credit-hours-for-my-course'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
DISCOUNT_CODE_INFO_URL=''
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:8734'
|
||||
EXAMS_BASE_URL=''
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
IGNORED_ERROR_REGEX=''
|
||||
@@ -49,3 +52,7 @@ SESSION_COOKIE_DOMAIN='localhost'
|
||||
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
|
||||
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
FEATURE_ENABLE_CHAT_V2_ENDPOINT='false'
|
||||
|
||||
@@ -4,17 +4,20 @@
|
||||
NODE_ENV='test'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
APP_ID='learning'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
|
||||
CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-college-credit-or-credit-hours-for-my-course'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
DISCOUNT_CODE_INFO_URL=''
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:8734'
|
||||
EXAMS_BASE_URL='http://localhost:18740'
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
IGNORED_ERROR_REGEX=''
|
||||
@@ -46,3 +49,6 @@ TWITTER_HASHTAG='myedxjourney'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
|
||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
||||
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:Enterprise'
|
||||
FEATURE_ENABLE_CHAT_V2_ENDPOINT='false'
|
||||
|
||||
@@ -3,3 +3,5 @@ dist/
|
||||
packages/
|
||||
node_modules/
|
||||
jest.config.js
|
||||
env.config.jsx
|
||||
example.env.config.jsx
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
const config = createConfig('eslint', {
|
||||
rules: {
|
||||
@@ -12,6 +12,13 @@ const config = createConfig('eslint', {
|
||||
'react/no-unknown-property': 'off',
|
||||
'func-names': 'off',
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
webpack: {
|
||||
config: 'webpack.prod.config.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = config;
|
||||
|
||||
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Adding new check for github-actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
18
.github/workflows/add-issue-to-btr-project.yml
vendored
Normal file
18
.github/workflows/add-issue-to-btr-project.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Run the workflow that adds new tickets that are labelled "release testing"
|
||||
# to the org-wide BTR project board
|
||||
|
||||
name: Add release testing issues to the BTR project board
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
# This workflow is triggered when an issue is labeled with 'release testing'.
|
||||
# It adds the issue to the BTR project and applies the 'needs triage' label
|
||||
# if it doesn't already have it.
|
||||
|
||||
jobs:
|
||||
handle-release-testing:
|
||||
uses: openedx/.github/.github/workflows/add-issue-to-btr-project.yml@master
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
25
.github/workflows/validate.yml
vendored
25
.github/workflows/validate.yml
vendored
@@ -10,14 +10,27 @@ jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
node-version-file: '.nvmrc'
|
||||
- run: make validate.ci
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: code-coverage-report
|
||||
path: coverage/*.*
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: code-coverage-report
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
env.config.*
|
||||
|
||||
dist/
|
||||
src/i18n/transifex_input.json
|
||||
@@ -25,3 +26,7 @@ module.config.js
|
||||
|
||||
# Local environment overrides
|
||||
.env.private
|
||||
|
||||
src/i18n/messages/
|
||||
|
||||
env.config.jsx
|
||||
|
||||
1
.husky/_/.gitignore
vendored
1
.husky/_/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
*
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/sh
|
||||
if [ -z "$husky_skip_init" ]; then
|
||||
debug () {
|
||||
if [ "$HUSKY_DEBUG" = "1" ]; then
|
||||
echo "husky (debug) - $1"
|
||||
fi
|
||||
}
|
||||
|
||||
readonly hook_name="$(basename "$0")"
|
||||
debug "starting $hook_name..."
|
||||
|
||||
if [ "$HUSKY" = "0" ]; then
|
||||
debug "HUSKY env variable is set to 0, skipping hook"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f ~/.huskyrc ]; then
|
||||
debug "sourcing ~/.huskyrc"
|
||||
. ~/.huskyrc
|
||||
fi
|
||||
|
||||
export readonly husky_skip_init=1
|
||||
sh -e "$0" "$@"
|
||||
exitCode="$?"
|
||||
|
||||
if [ $exitCode != 0 ]; then
|
||||
echo "husky - $hook_name hook exited with code $exitCode (error)"
|
||||
fi
|
||||
|
||||
exit $exitCode
|
||||
fi
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npm run lint
|
||||
@@ -1,9 +0,0 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[o:open-edx:p:edx-platform:r:frontend-app-learning]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
|
||||
41
Makefile
41
Makefile
@@ -1,20 +1,17 @@
|
||||
export TRANSIFEX_RESOURCE=frontend-app-learning
|
||||
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fa_IR,fr_CA,it_IT,pt_PT,de_DE"
|
||||
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
transifex_input = $(i18n)/transifex_input.json
|
||||
|
||||
# This directory must match .babelrc .
|
||||
transifex_temp = ./temp/babel-plugin-react-intl
|
||||
transifex_temp = ./temp/babel-plugin-formatjs
|
||||
|
||||
precommit:
|
||||
npm run lint
|
||||
npm audit
|
||||
|
||||
requirements:
|
||||
npm install
|
||||
npm ci
|
||||
|
||||
i18n.extract:
|
||||
# Pulling display strings from .jsx files into .json files...
|
||||
@@ -32,35 +29,21 @@ detect_changed_source_translations:
|
||||
# Checking for changed translations...
|
||||
git diff --exit-code $(i18n)
|
||||
|
||||
# Pushes translations to Transifex. You must run make extract_translations first.
|
||||
push_translations:
|
||||
# Pushing strings to Transifex...
|
||||
tx push -s
|
||||
# Fetching hashes from Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
|
||||
# Writing out comments to file...
|
||||
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
ifeq ($(OPENEDX_ATLAS_PULL),)
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -f --mode reviewed --languages=$(transifex_langs)
|
||||
else
|
||||
# Experimental: OEP-58 Pulls translations using atlas
|
||||
pull_translations:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull --filter=$(transifex_langs) \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
|
||||
&& atlas pull $(ATLAS_OPTIONS) \
|
||||
translations/frontend-platform/src/i18n/messages:frontend-platform \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-lib-special-exams/src/i18n/messages:frontend-lib-special-exams \
|
||||
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
|
||||
|
||||
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-lib-special-exams frontend-app-learning
|
||||
|
||||
$(intl_imports) paragon frontend-component-header frontend-component-footer frontend-app-learning
|
||||
endif
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
@@ -72,8 +55,10 @@ validate:
|
||||
make validate-no-uncommitted-package-lock-changes
|
||||
npm run i18n_extract
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run types
|
||||
npm run test
|
||||
npm run build
|
||||
npm run bundlewatch
|
||||
|
||||
.PHONY: validate.ci
|
||||
validate.ci:
|
||||
|
||||
188
README.rst
188
README.rst
@@ -1,38 +1,109 @@
|
||||
frontend-app-learning
|
||||
#####################
|
||||
|
||||
|codecov| |license|
|
||||
|
||||
frontend-app-learning
|
||||
=========================
|
||||
|
||||
Introduction
|
||||
------------
|
||||
Purpose
|
||||
*******
|
||||
|
||||
This is the Learning MFE (micro-frontend application), which renders all
|
||||
learner-facing course pages (like the course outline, the progress page,
|
||||
actual course content, etc).
|
||||
|
||||
Please tag **@edx/engage-squad** on any PRs or issues. Thanks.
|
||||
|
||||
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
|
||||
:target: https://codecov.io/gh/edx/frontend-app-learning
|
||||
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
|
||||
:target: https://github.com/openedx/frontend-app-account/blob/master/LICENSE
|
||||
|
||||
Development
|
||||
-----------
|
||||
Getting Started
|
||||
***************
|
||||
|
||||
Start Devstack
|
||||
^^^^^^^^^^^^^^
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
|
||||
`Tutor`_ is currently recommended as a development environment for the Learning
|
||||
MFE. Most likely, it already has this MFE configured; however, you'll need to
|
||||
make some changes in order to run it in development mode. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ for details, or follow the quick
|
||||
guide below.
|
||||
|
||||
- Run ``make dev.up.lms``
|
||||
- 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.
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
||||
|
||||
|
||||
Cloning and Setup
|
||||
=================
|
||||
|
||||
1. Clone your new repo:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git clone https://github.com/openedx/frontend-app-learning.git
|
||||
|
||||
2. Use the version of Node specified in ``.nvmrc``.
|
||||
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes an ``.nvmrc`` file to help in setting the
|
||||
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
|
||||
|
||||
3. Stop the Tutor devstack, if it's running: ``tutor dev stop``
|
||||
|
||||
4. Next, we need to tell Tutor that we're going to be running this repo in
|
||||
development mode, and it should be excluded from the ``mfe`` container that
|
||||
otherwise runs every MFE. Run this:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tutor mounts add /path/to/frontend-app-learning
|
||||
|
||||
5. Start Tutor in development mode. This command will start the LMS and Studio,
|
||||
and other required MFEs like ``authn`` and ``account``, but will not start
|
||||
the learning MFE, which we're going to run on the host instead of in a
|
||||
container managed by Tutor. Run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tutor dev start lms cms mfe
|
||||
|
||||
Startup
|
||||
=======
|
||||
|
||||
1. Install npm dependencies:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
cd frontend-app-learning && npm ci
|
||||
|
||||
2. Start the dev server:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
npm run dev
|
||||
|
||||
Then you can access the app at http://local.openedx.io:2000/learning/
|
||||
|
||||
Troubleshooting
|
||||
---------------
|
||||
|
||||
If you see an "Invalid Host header" error, then you're probably using a different domain name for your devstack such as
|
||||
``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run
|
||||
these commands to update your devstack's domain names:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tutor dev stop
|
||||
tutor config save --set LMS_HOST=local.openedx.io --set CMS_HOST=studio.local.openedx.io
|
||||
tutor dev launch -I --skip-build
|
||||
tutor dev stop learning # We will run this MFE on the host
|
||||
|
||||
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::
|
||||
file (which is git-ignored) that defines where to find your local modules, for instance:
|
||||
|
||||
.. code-block:: js
|
||||
|
||||
module.exports = {
|
||||
/*
|
||||
@@ -45,29 +116,35 @@ file (which is git-ignored) that defines where to find your local modules, for i
|
||||
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' },
|
||||
{ moduleName: '@openedx/paragon/scss', dir: '../paragon', dist: 'scss' },
|
||||
{ moduleName: '@openedx/paragon', dir: '../paragon', dist: 'dist' },
|
||||
{ moduleName: '@openedx/frontend-enterprise', dir: '../frontend-enterprise', dist: 'src' },
|
||||
{ moduleName: '@openedx/frontend-platform', dir: '../frontend-platform', dist: 'dist' },
|
||||
],
|
||||
};
|
||||
|
||||
See https://github.com/openedx/frontend-build#local-module-configuration-for-webpack for more details.
|
||||
|
||||
Deployment
|
||||
----------
|
||||
==========
|
||||
|
||||
The Learning MFE is similar to all the other Open edX MFEs. Read the Open
|
||||
edX Developer Guide's section on
|
||||
`MFE applications <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html>`_.
|
||||
`MFE applications <https://openedx.github.io/frontend-platform/>`_.
|
||||
|
||||
Plugins
|
||||
=======
|
||||
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
|
||||
|
||||
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
|
||||
|
||||
Environment Variables
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
=====================
|
||||
|
||||
This MFE is configured via environment variables supplied at build time.
|
||||
All micro-frontends have a shared set of required environment variables,
|
||||
as documented in the Open edX Developer Guide under
|
||||
`Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`_.
|
||||
`Required Environment Variables <https://openedx.github.io/frontend-platform/>`_.
|
||||
|
||||
The learning micro-frontend also supports the following additional variables:
|
||||
|
||||
@@ -88,7 +165,7 @@ SOCIAL_UTM_MILESTONE_CAMPAIGN
|
||||
|
||||
SUPPORT_URL_CALCULATOR_MATH
|
||||
A link that explains how to use the in-course calculator. You can use the
|
||||
one in the example below, if you don't want to have your own branded version.
|
||||
one in the example below if you don't want to have your own branded version.
|
||||
|
||||
Example: https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator
|
||||
|
||||
@@ -101,7 +178,7 @@ SUPPORT_URL_ID_VERIFICATION
|
||||
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE
|
||||
A link that explains what a verified certificate is. You can use the
|
||||
one in the example below, if you don't want to have your own branded version.
|
||||
one in the example below if you don't want to have your own branded version.
|
||||
Optional.
|
||||
|
||||
Example: https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate
|
||||
@@ -117,5 +194,62 @@ TWITTER_URL
|
||||
A link to your Twitter account. The Twitter social-share link won't appear
|
||||
unless this is set. Optional.
|
||||
|
||||
Example: https://twitter.com/edXOnline
|
||||
Example: https://twitter.com/openedx
|
||||
|
||||
Getting Help
|
||||
============
|
||||
|
||||
If you're having trouble, we have `discussion forums`_
|
||||
where you can connect with others in the community.
|
||||
|
||||
Our real-time conversations are on Slack. You can request a `Slack
|
||||
invitation`_, then join our `community Slack workspace`_. Because this is a
|
||||
frontend repository, the best place to discuss it would be in the `#wg-frontend
|
||||
channel`_.
|
||||
|
||||
For anything non-trivial, the best path is to open an issue in this repository
|
||||
with as many details about the issue you are facing as you can provide.
|
||||
|
||||
https://github.com/openedx/frontend-app-learning/issues
|
||||
|
||||
For more information about these options, see the `Getting Help`_ page.
|
||||
|
||||
.. _Slack invitation: https://openedx.org/slack
|
||||
.. _community Slack workspace: https://openedx.slack.com/
|
||||
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
|
||||
.. _Getting Help: https://openedx.org/community/connect
|
||||
.. _discussion forums: https://discuss.openedx.org
|
||||
|
||||
Contributing
|
||||
============
|
||||
|
||||
Contributions are very welcome. Please read `How To Contribute`_ for details.
|
||||
|
||||
.. _How To Contribute: https://openedx.org/r/how-to-contribute
|
||||
|
||||
This project is currently accepting all types of contributions, bug fixes,
|
||||
security fixes, maintenance work, or new features. However, please make sure
|
||||
to discuss your new feature idea with the maintainers before
|
||||
beginning development to maximize the chances of your change being accepted.
|
||||
You can start a conversation by creating a new issue on this repo summarizing
|
||||
your idea.
|
||||
|
||||
The Open edX Code of Conduct
|
||||
============================
|
||||
|
||||
All community members are expected to follow the `Open edX Code of Conduct`_.
|
||||
|
||||
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
|
||||
|
||||
License
|
||||
=======
|
||||
|
||||
The code in this repository is licensed under the AGPLv3 unless otherwise
|
||||
noted.
|
||||
|
||||
Please see `LICENSE <LICENSE>`_ for details.
|
||||
|
||||
Reporting Security Issues
|
||||
=========================
|
||||
|
||||
Please do not report security issues in public. Please email security@openedx.org.
|
||||
|
||||
19
catalog-info.yaml
Normal file
19
catalog-info.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
# This file records information about this repo. Its use is described in OEP-55:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
|
||||
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: 'frontend-app-learning'
|
||||
description: "This is the Learning MFE, which renders all learner-facing course pages."
|
||||
links:
|
||||
- url: "https://github.com/openedx/frontend-app-learning"
|
||||
title: "Learning MFE"
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
openedx.org/release: "master"
|
||||
spec:
|
||||
owner: group:committers-frontend-app-learning
|
||||
type: 'website'
|
||||
lifecycle: 'production'
|
||||
24
example.env.config.jsx
Normal file
24
example.env.config.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import UnitTranslationPlugin from '@edx/unit-translation-selector-plugin';
|
||||
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
// Load environment variables from .env file
|
||||
const config = {
|
||||
...process.env,
|
||||
pluginSlots: {
|
||||
unit_title_plugin: {
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'unit_title_plugin',
|
||||
type: DIRECT_PLUGIN,
|
||||
priority: 1,
|
||||
RenderWidget: UnitTranslationPlugin,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
4
global-setup.js
Normal file
4
global-setup.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// Force all tests to run in UTC to prevent tests from being sensitive to host timezone.
|
||||
module.exports = async () => {
|
||||
process.env.TZ = 'UTC';
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
const config = createConfig('jest', {
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
@@ -9,12 +9,35 @@ module.exports = createConfig('jest', {
|
||||
'src/i18n',
|
||||
'src/.*\\.exp\\..*',
|
||||
],
|
||||
// see https://github.com/axios/axios/issues/5026
|
||||
moduleNameMapper: {
|
||||
"^axios$": "axios/dist/axios.js",
|
||||
// See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
|
||||
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
|
||||
'@src/(.*)': '<rootDir>/src/$1',
|
||||
// Explicit mapping to ensure Jest resolves the module correctly
|
||||
'@edx/frontend-lib-special-exams': '<rootDir>/node_modules/@edx/frontend-lib-special-exams',
|
||||
},
|
||||
testTimeout: 30000,
|
||||
testEnvironment: 'jsdom'
|
||||
globalSetup: "./global-setup.js",
|
||||
verbose: true,
|
||||
testEnvironment: 'jsdom',
|
||||
});
|
||||
|
||||
// delete config.testURL;
|
||||
|
||||
config.reporters = [...(config.reporters || []), ["jest-console-group-reporter", {
|
||||
// change this setting if need to see less details for each test
|
||||
// reportType: "summary" | "details",
|
||||
// enable: true | false,
|
||||
afterEachTest: {
|
||||
enable: true,
|
||||
filePaths: false,
|
||||
reportType: "details",
|
||||
},
|
||||
afterAllTests: {
|
||||
reportType: "summary",
|
||||
enable: true,
|
||||
filePaths: true,
|
||||
},
|
||||
}]];
|
||||
|
||||
module.exports = config;
|
||||
|
||||
10
openedx.yaml
10
openedx.yaml
@@ -1,10 +0,0 @@
|
||||
# This file describes this Open edX repo, as described in OEP-2:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0002-bp-repo-metadata.html#specification
|
||||
|
||||
oeps: {}
|
||||
owner: edx/platform-core-tnl
|
||||
openedx-release:
|
||||
# The openedx-release key is described in OEP-10:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
|
||||
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
|
||||
ref: master
|
||||
22944
package-lock.json
generated
22944
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
92
package.json
92
package.json
@@ -11,13 +11,16 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
|
||||
"prepare": "husky install",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"bundlewatch": "bundlewatch",
|
||||
"i18n_extract": "fedx-scripts formatjs extract",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
|
||||
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
"start:with-theme": "paragon install-theme && npm start && npm install",
|
||||
"dev": "PUBLIC_PATH=/learning/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
|
||||
"test": "NODE_ENV=test fedx-scripts jest --coverage --passWithNoTests",
|
||||
"test:watch": "fedx-scripts jest --watch --passWithNoTests",
|
||||
"types": "tsc --noEmit"
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
@@ -29,54 +32,67 @@
|
||||
"url": "https://github.com/openedx/frontend-app-learning/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
||||
"@edx/frontend-component-footer": "12.2.1",
|
||||
"@edx/frontend-component-header": "4.6.0",
|
||||
"@edx/frontend-lib-learning-assistant": "^1.14.0",
|
||||
"@edx/frontend-lib-special-exams": "2.23.2",
|
||||
"@edx/frontend-platform": "5.0.0",
|
||||
"@edx/paragon": "20.46.0",
|
||||
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/browserslist-config": "1.5.0",
|
||||
"@edx/frontend-component-footer": "^14.6.0",
|
||||
"@edx/frontend-component-header": "^8.0.0",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.23.1",
|
||||
"@edx/frontend-lib-special-exams": "^4.0.0",
|
||||
"@edx/frontend-platform": "^8.4.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"@openedx/frontend-build": "^14.6.2",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^23.4.5",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@reduxjs/toolkit": "1.8.1",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.22.2",
|
||||
"history": "5.3.0",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "2.5.1",
|
||||
"copy-webpack-plugin": "^12.0.0",
|
||||
"joi": "^17.11.0",
|
||||
"js-cookie": "3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"prop-types": "15.8.1",
|
||||
"query-string": "^7.1.3",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router": "6.15.0",
|
||||
"react-router-dom": "6.15.0",
|
||||
"react-share": "4.4.1",
|
||||
"redux": "4.1.2",
|
||||
"regenerator-runtime": "0.13.11",
|
||||
"redux": "4.2.1",
|
||||
"reselect": "4.1.8",
|
||||
"truncate-html": "1.0.4",
|
||||
"util": "0.12.5"
|
||||
"sass": "^1.79.3",
|
||||
"sass-loader": "^16.0.2",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"truncate-html": "1.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-build": "^12.9.10",
|
||||
"@edx/reactifex": "2.2.0",
|
||||
"@pact-foundation/pact": "^11.0.2",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"axios-mock-adapter": "1.20.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"es-check": "6.2.1",
|
||||
"husky": "7.0.4",
|
||||
"jest": "29.5.0",
|
||||
"rosie": "2.1.0"
|
||||
"@pact-foundation/pact": "^13.0.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"axios-mock-adapter": "2.1.0",
|
||||
"bundlewatch": "^0.4.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.9",
|
||||
"jest": "^29.7.0",
|
||||
"jest-console-group-reporter": "^1.1.1",
|
||||
"jest-when": "^3.6.0",
|
||||
"rosie": "2.1.1"
|
||||
},
|
||||
"bundlewatch": {
|
||||
"files": [
|
||||
{
|
||||
"path": "dist/*.js",
|
||||
"maxSize": "1450kB"
|
||||
}
|
||||
],
|
||||
"normalizeFilenames": "^.+?(\\..+?)\\.\\w+$"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
<% if (htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID) { %>
|
||||
<script src="https://www.edx.org/optimizelyjs/<%= htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID %>.js"></script>
|
||||
<% } %>
|
||||
<% if (htmlWebpackPlugin.options.META_TAG_ROBOTS_CONTENT_ATTR) { %>
|
||||
<meta name="robots" content="<%= htmlWebpackPlugin.options.META_TAG_ROBOTS_CONTENT_ATTR %>">
|
||||
<% } %>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"rebaseStalePrs": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["@edx"],
|
||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
FormattedMessage, FormattedDate, injectIntl, intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
import { FormattedMessage, FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Hyperlink } from '@openedx/paragon';
|
||||
import { Info } from '@openedx/paragon/icons';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const AccessExpirationAlert = ({ intl, payload }) => {
|
||||
const AccessExpirationAlert = ({ payload }) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
accessExpiration,
|
||||
courseId,
|
||||
@@ -119,7 +118,6 @@ const AccessExpirationAlert = ({ intl, payload }) => {
|
||||
};
|
||||
|
||||
AccessExpirationAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
accessExpiration: PropTypes.shape({
|
||||
expirationDate: PropTypes.string.isRequired,
|
||||
@@ -134,4 +132,4 @@ AccessExpirationAlert.propTypes = {
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccessExpirationAlert);
|
||||
export default AccessExpirationAlert;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
import { PageBanner } from '@edx/paragon';
|
||||
import { PageBanner } from '@openedx/paragon';
|
||||
|
||||
const AccessExpirationMasqueradeBanner = ({ payload }) => {
|
||||
const {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { WarningFilled } from '@edx/paragon/icons';
|
||||
import { Alert, Hyperlink } from '@openedx/paragon';
|
||||
import { WarningFilled } from '@openedx/paragon/icons';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import genericMessages from './messages';
|
||||
|
||||
const ActiveEnterpriseAlert = ({ intl, payload }) => {
|
||||
const ActiveEnterpriseAlert = ({ payload }) => {
|
||||
const intl = useIntl();
|
||||
const { text, courseId } = payload;
|
||||
const changeActiveEnterprise = (
|
||||
<Hyperlink
|
||||
@@ -38,11 +39,10 @@ const ActiveEnterpriseAlert = ({ intl, payload }) => {
|
||||
};
|
||||
|
||||
ActiveEnterpriseAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
text: PropTypes.string,
|
||||
courseId: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ActiveEnterpriseAlert);
|
||||
export default ActiveEnterpriseAlert;
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
FormattedRelativeTime,
|
||||
FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
import { Alert } from '@openedx/paragon';
|
||||
import { Info } from '@openedx/paragon/icons';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
import { PageBanner } from '@edx/paragon';
|
||||
import { PageBanner } from '@openedx/paragon';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Button } from '@edx/paragon';
|
||||
import { Info, WarningFilled } from '@edx/paragon/icons';
|
||||
import { Alert, Button } from '@openedx/paragon';
|
||||
import { Info, WarningFilled } from '@openedx/paragon/icons';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
@@ -11,7 +11,8 @@ import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
import useEnrollClickHandler from './clickHook';
|
||||
|
||||
const EnrollmentAlert = ({ intl, payload }) => {
|
||||
const EnrollmentAlert = ({ payload }) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
canEnroll,
|
||||
courseId,
|
||||
@@ -58,7 +59,6 @@ const EnrollmentAlert = ({ intl, payload }) => {
|
||||
};
|
||||
|
||||
EnrollmentAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
canEnroll: PropTypes.bool,
|
||||
courseId: PropTypes.string,
|
||||
@@ -67,4 +67,4 @@ EnrollmentAlert.propTypes = {
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(EnrollmentAlert);
|
||||
export default EnrollmentAlert;
|
||||
|
||||
@@ -7,15 +7,14 @@ import {
|
||||
Button,
|
||||
Spinner,
|
||||
Icon,
|
||||
} from '@edx/paragon';
|
||||
import { Check, ArrowForward } from '@edx/paragon/icons';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
} from '@openedx/paragon';
|
||||
import { Check, ArrowForward } from '@openedx/paragon/icons';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { sendActivationEmail } from '../../courseware/data';
|
||||
import messages from './messages';
|
||||
|
||||
const AccountActivationAlert = ({
|
||||
intl,
|
||||
}) => {
|
||||
const AccountActivationAlert = () => {
|
||||
const intl = useIntl();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showSpinner, setShowSpinner] = useState(false);
|
||||
const [showCheck, setShowCheck] = useState(false);
|
||||
@@ -125,8 +124,4 @@ const AccountActivationAlert = ({
|
||||
);
|
||||
};
|
||||
|
||||
AccountActivationAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccountActivationAlert);
|
||||
export default AccountActivationAlert;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
|
||||
import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { WarningFilled } from '@edx/paragon/icons';
|
||||
import { Alert, Hyperlink } from '@openedx/paragon';
|
||||
import { WarningFilled } from '@openedx/paragon/icons';
|
||||
|
||||
import genericMessages from '../../generic/messages';
|
||||
|
||||
const LogistrationAlert = ({ intl }) => {
|
||||
const LogistrationAlert = () => {
|
||||
const intl = useIntl();
|
||||
const signIn = (
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
@@ -43,8 +44,4 @@ const LogistrationAlert = ({ intl }) => {
|
||||
);
|
||||
};
|
||||
|
||||
LogistrationAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LogistrationAlert);
|
||||
export default LogistrationAlert;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
export const DECODE_ROUTES = {
|
||||
ACCESS_DENIED: '/course/:courseId/access-denied',
|
||||
HOME: '/course/:courseId/home',
|
||||
LIVE: '/course/:courseId/live',
|
||||
DATES: '/course/:courseId/dates',
|
||||
DISCUSSION: '/course/:courseId/discussion/:path/*',
|
||||
PROGRESS: [
|
||||
'/course/:courseId/progress/:targetUserId/',
|
||||
'/course/:courseId/progress',
|
||||
],
|
||||
COURSE_END: '/course/:courseId/course-end',
|
||||
COURSEWARE: [
|
||||
'/course/:courseId/:sequenceId/:unitId',
|
||||
'/course/:courseId/:sequenceId',
|
||||
'/course/:courseId',
|
||||
],
|
||||
REDIRECT_HOME: 'home/:courseId',
|
||||
REDIRECT_SURVEY: 'survey/:courseId',
|
||||
};
|
||||
|
||||
export const ROUTES = {
|
||||
UNSUBSCRIBE: '/goal-unsubscribe/:token',
|
||||
REDIRECT: '/redirect/*',
|
||||
DASHBOARD: 'dashboard',
|
||||
CONSENT: 'consent',
|
||||
};
|
||||
|
||||
export const REDIRECT_MODES = {
|
||||
DASHBOARD_REDIRECT: 'dashboard-redirect',
|
||||
CONSENT_REDIRECT: 'consent-redirect',
|
||||
HOME_REDIRECT: 'home-redirect',
|
||||
SURVEY_REDIRECT: 'survey-redirect',
|
||||
};
|
||||
74
src/constants.ts
Normal file
74
src/constants.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export const DECODE_ROUTES = {
|
||||
ACCESS_DENIED: '/course/:courseId/access-denied',
|
||||
HOME: '/course/:courseId/home',
|
||||
LIVE: '/course/:courseId/live',
|
||||
DATES: '/course/:courseId/dates',
|
||||
DISCUSSION: '/course/:courseId/discussion/:path/*',
|
||||
PROGRESS: [
|
||||
'/course/:courseId/progress/:targetUserId/',
|
||||
'/course/:courseId/progress',
|
||||
],
|
||||
COURSE_END: '/course/:courseId/course-end',
|
||||
COURSEWARE: [
|
||||
'/course/:courseId/:sequenceId/:unitId',
|
||||
'/course/:courseId/:sequenceId',
|
||||
'/course/:courseId',
|
||||
'/preview/course/:courseId/:sequenceId/:unitId',
|
||||
'/preview/course/:courseId/:sequenceId',
|
||||
],
|
||||
REDIRECT_HOME: 'home/:courseId',
|
||||
REDIRECT_SURVEY: 'survey/:courseId',
|
||||
} as const satisfies Readonly<{ [k: string]: string | readonly string[] }>;
|
||||
|
||||
export const ROUTES = {
|
||||
UNSUBSCRIBE: '/goal-unsubscribe/:token',
|
||||
PREFERENCES_UNSUBSCRIBE: '/preferences-unsubscribe/:userToken/:updatePatch?',
|
||||
REDIRECT: '/redirect/*',
|
||||
DASHBOARD: 'dashboard',
|
||||
ENTERPRISE_LEARNER_DASHBOARD: 'enterprise-learner-dashboard',
|
||||
CONSENT: 'consent',
|
||||
} as const satisfies Readonly<{ [k: string]: string }>;
|
||||
|
||||
export const REDIRECT_MODES = {
|
||||
DASHBOARD_REDIRECT: 'dashboard-redirect',
|
||||
ENTERPRISE_LEARNER_DASHBOARD_REDIRECT: 'enterprise-learner-dashboard-redirect',
|
||||
CONSENT_REDIRECT: 'consent-redirect',
|
||||
HOME_REDIRECT: 'home-redirect',
|
||||
SURVEY_REDIRECT: 'survey-redirect',
|
||||
} as const satisfies Readonly<{ [k: string]: string }>;
|
||||
|
||||
export const VERIFIED_MODES = [
|
||||
'professional',
|
||||
'verified',
|
||||
'no-id-professional',
|
||||
'credit',
|
||||
'masters',
|
||||
'executive-education',
|
||||
'paid-executive-education',
|
||||
'paid-bootcamp',
|
||||
] as const satisfies readonly string[];
|
||||
|
||||
export const AUDIT_MODES = [
|
||||
'audit',
|
||||
'honor',
|
||||
'unpaid-executive-education',
|
||||
'unpaid-bootcamp',
|
||||
] as const satisfies readonly string[];
|
||||
|
||||
// In sync with CourseMode.UPSELL_TO_VERIFIED_MODES
|
||||
// https://github.com/openedx/edx-platform/blob/master/common/djangoapps/course_modes/models.py#L231
|
||||
export const ALLOW_UPSELL_MODES = [
|
||||
'audit',
|
||||
'honor',
|
||||
] as const satisfies readonly string[];
|
||||
|
||||
export const WIDGETS = {
|
||||
DISCUSSIONS: 'DISCUSSIONS',
|
||||
NOTIFICATIONS: 'NOTIFICATIONS',
|
||||
} as const satisfies Readonly<{ [k: string]: string }>;
|
||||
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
export const DENIED = 'denied';
|
||||
export type StatusValue = typeof LOADING | typeof LOADED | typeof FAILED | typeof DENIED;
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Tabs, Tab } from '@openedx/paragon';
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
import CoursewareSearchResults from './CoursewareSearchResults';
|
||||
import messages from './messages';
|
||||
import { useCoursewareSearchParams } from './hooks';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const filterAll = 'all';
|
||||
const filterTypes = ['text', 'video', 'sequence'];
|
||||
const filterOther = 'other';
|
||||
const validFilters = [filterAll, ...filterTypes, filterOther];
|
||||
|
||||
export const CoursewareSearchResultsFilter = () => {
|
||||
const intl = useIntl();
|
||||
const { courseId } = useParams();
|
||||
const lastSearch = useModel('contentSearchResults', courseId);
|
||||
const { filter: filterKeyword, setFilter } = useCoursewareSearchParams();
|
||||
|
||||
if (!lastSearch) { return null; }
|
||||
|
||||
const { results: data = [] } = lastSearch;
|
||||
|
||||
// If there's no data, we show an empty result.
|
||||
if (!data.length) { return <CoursewareSearchResults />; }
|
||||
|
||||
const results = useMemo(() => {
|
||||
// This reducer distributes the data into different groups to make it easy to
|
||||
// use on the filters.
|
||||
// All results are added to the "all" key and then to its proper group key as well.
|
||||
const grouped = data.reduce((acc, { type, ...rest }) => {
|
||||
const resultType = filterTypes.includes(type) ? type : filterOther;
|
||||
acc[filterAll].push({ type: resultType, ...rest });
|
||||
acc[resultType] = [...(acc[resultType] || []), { type: resultType, ...rest }];
|
||||
return acc;
|
||||
}, { [filterAll]: [] });
|
||||
|
||||
// This is just to format the output object with the expected tab order.
|
||||
const output = {};
|
||||
validFilters.forEach(key => { if (grouped[key]) { output[key] = grouped[key]; } });
|
||||
|
||||
return output;
|
||||
}, [lastSearch]);
|
||||
|
||||
const tabKeys = Object.keys(results);
|
||||
// Filter has no use if it has only 2 tabs (The "all" tab and another one with the same items).
|
||||
if (tabKeys.length < 3) { return <CoursewareSearchResults results={results[filterAll]} />; }
|
||||
|
||||
const filters = useMemo(() => tabKeys.map((key) => ({
|
||||
key,
|
||||
label: intl.formatMessage(messages[`filter:${key}`]),
|
||||
count: results[key].length,
|
||||
})), [results]);
|
||||
|
||||
const activeKey = validFilters.includes(filterKeyword) ? filterKeyword : filterAll;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
id="courseware-search-results-tabs"
|
||||
className="courseware-search-results-tabs"
|
||||
data-testid="courseware-search-results-tabs"
|
||||
variant="tabs"
|
||||
activeKey={activeKey}
|
||||
onSelect={setFilter}
|
||||
>
|
||||
{filters.filter(({ count }) => (count > 0)).map(({ key, label }) => (
|
||||
<Tab key={key} eventKey={key} title={label} data-testid={`courseware-search-results-tabs-${key}`}>
|
||||
<CoursewareSearchResults results={results[key]} />
|
||||
</Tab>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursewareSearchResultsFilter;
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import {
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '../../setupTest';
|
||||
import { CoursewareSearchResultsFilter } from './CoursewareResultsFilter';
|
||||
import { useCoursewareSearchParams } from './hooks';
|
||||
import initializeStore from '../../store';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import searchResultsFactory from './test-data/search-results-factory';
|
||||
|
||||
jest.mock('./hooks');
|
||||
jest.mock('../../generic/model-store', () => ({
|
||||
useModel: jest.fn(),
|
||||
}));
|
||||
|
||||
global.ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
}));
|
||||
|
||||
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const decodedSequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
|
||||
const decodedUnitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
|
||||
const pathname = `/course/${decodedCourseId}/${decodedSequenceId}/${decodedUnitId}`;
|
||||
|
||||
const intl = {
|
||||
formatMessage: (message) => message?.defaultMessage || '',
|
||||
};
|
||||
|
||||
const coursewareSearch = {
|
||||
query: '',
|
||||
filter: '',
|
||||
setQuery: jest.fn(),
|
||||
setFilter: jest.fn(),
|
||||
clearSearchParams: jest.fn(),
|
||||
};
|
||||
|
||||
function renderComponent(props = {}) {
|
||||
const store = initializeStore();
|
||||
history.push(pathname);
|
||||
const { container } = render(
|
||||
<AppProvider store={store}>
|
||||
<Routes>
|
||||
<Route path="/course/:courseId/:sequenceId/:unitId" element={<CoursewareSearchResultsFilter intl={intl} {...props} />} />
|
||||
</Routes>
|
||||
</AppProvider>,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearchResultsFilter', () => {
|
||||
beforeAll(initializeMockApp);
|
||||
|
||||
beforeEach(() => {
|
||||
useCoursewareSearchParams.mockReturnValue(coursewareSearch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when returning full results', () => {
|
||||
beforeEach(() => {
|
||||
useModel.mockReturnValue(searchResultsFactory());
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
it('should render without errors', async () => {
|
||||
await waitFor(() => {
|
||||
expect(useCoursewareSearchParams).toBeCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs-all')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs-text')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs-video')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs-sequence')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs-other')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when returning only one result type', () => {
|
||||
beforeEach(async () => {
|
||||
// Get results for only videos
|
||||
const data = searchResultsFactory();
|
||||
const onlyVideos = data.results.filter(({ type }) => type === 'video');
|
||||
const filteredResults = {
|
||||
...data,
|
||||
results: onlyVideos,
|
||||
};
|
||||
|
||||
useModel.mockReturnValue(filteredResults);
|
||||
await renderComponent();
|
||||
});
|
||||
|
||||
it('should not render', async () => {
|
||||
await waitFor(() => {
|
||||
expect(useCoursewareSearchParams).toBeCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there are not results', () => {
|
||||
beforeEach(async () => {
|
||||
useModel.mockReturnValue(searchResultsFactory('blah', {
|
||||
results: [],
|
||||
filters: [],
|
||||
total: 0,
|
||||
maxScore: null,
|
||||
ms: 5,
|
||||
}));
|
||||
await renderComponent();
|
||||
});
|
||||
|
||||
it('should not render', async () => {
|
||||
await waitFor(() => {
|
||||
expect(useCoursewareSearchParams).toBeCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
176
src/course-home/courseware-search/CoursewareSearch.jsx
Normal file
176
src/course-home/courseware-search/CoursewareSearch.jsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert, Button, Icon, Spinner,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
Close,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
import { useCoursewareSearchParams, useElementBoundingBox, useLockScroll } from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
import CoursewareSearchForm from './CoursewareSearchForm';
|
||||
import CoursewareSearchResultsFilterContainer from './CoursewareResultsFilter';
|
||||
import { updateModel, useModel } from '../../generic/model-store';
|
||||
import { searchCourseContent } from '../data/thunks';
|
||||
|
||||
const CoursewareSearch = ({ ...sectionProps }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { courseId } = useParams();
|
||||
const { query: searchKeyword, setQuery, clearSearchParams } = useCoursewareSearchParams();
|
||||
const dispatch = useDispatch();
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
loading,
|
||||
searchKeyword: lastSearchKeyword,
|
||||
errors,
|
||||
total,
|
||||
} = useModel('contentSearchResults', courseId);
|
||||
const dialogRef = useRef();
|
||||
|
||||
useLockScroll();
|
||||
|
||||
const info = useElementBoundingBox('courseTabsNavigation');
|
||||
const top = info ? `${Math.floor(info.top)}px` : 0;
|
||||
|
||||
const clearSearch = () => {
|
||||
clearSearchParams();
|
||||
dispatch(updateModel({
|
||||
modelType: 'contentSearchResults',
|
||||
model: {
|
||||
id: courseId,
|
||||
searchKeyword: '',
|
||||
results: [],
|
||||
errors: undefined,
|
||||
loading:
|
||||
false,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = (value) => {
|
||||
if (!value) {
|
||||
clearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
sendTrackingLogEvent('edx.course.home.courseware_search.submit', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
event_type: 'searchKeyword',
|
||||
keyword: value,
|
||||
});
|
||||
|
||||
dispatch(searchCourseContent(courseId, value));
|
||||
setQuery(value);
|
||||
};
|
||||
|
||||
const handleOnChange = (value) => {
|
||||
if (value === searchKeyword) { return; }
|
||||
if (!value) { clearSearch(); }
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
clearSearch();
|
||||
dispatch(setShowSearch(false));
|
||||
};
|
||||
|
||||
const handlePopState = () => close();
|
||||
|
||||
const handleBackdropClick = function (event) {
|
||||
if (event.target === dialogRef.current) {
|
||||
dialogRef.current.close();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// We need this to keep the dialog reference when unmounting.
|
||||
const dialog = dialogRef.current;
|
||||
|
||||
// Open the dialog as a modal on render to confine focus within it.
|
||||
dialogRef.current.showModal();
|
||||
|
||||
if (searchKeyword) {
|
||||
handleSubmit(searchKeyword); // In case it's opened with a search link, we run the search.
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
window.addEventListener('popstate', handlePopState, { signal });
|
||||
dialog.addEventListener('click', handleBackdropClick, { signal });
|
||||
|
||||
return () => controller.abort(); // Removes event listeners.
|
||||
}, []);
|
||||
|
||||
const handleSearchClose = () => close();
|
||||
|
||||
let status = 'idle';
|
||||
if (loading) {
|
||||
status = 'loading';
|
||||
} else if (errors) {
|
||||
status = 'error';
|
||||
} else if (lastSearchKeyword) {
|
||||
status = 'results';
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} className="courseware-search" style={{ '--modal-top-position': top }} data-testid="courseware-search-dialog" onClose={handleSearchClose} {...sectionProps}>
|
||||
<div className="courseware-search__outer-content">
|
||||
<div className="courseware-search__content" data-testid="courseware-search-content">
|
||||
<div className="courseware-search__form">
|
||||
<h1 className="h2">{formatMessage(messages.searchModuleTitle)}</h1>
|
||||
<CoursewareSearchForm
|
||||
searchTerm={searchKeyword}
|
||||
onSubmit={handleSubmit}
|
||||
onChange={handleOnChange}
|
||||
placeholder={formatMessage(messages.searchBarPlaceholderText)}
|
||||
/>
|
||||
<div className="courseware-search__close">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="p-1"
|
||||
aria-label={formatMessage(messages.searchCloseAction)}
|
||||
onClick={() => dialogRef.current.close()}
|
||||
data-testid="courseware-search-close-button"
|
||||
><Icon src={Close} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="courseware-search__results" aria-live="polite" data-testid="courseware-search-results">
|
||||
{status === 'loading' ? (
|
||||
<div className="courseware-search__spinner" data-testid="courseware-search-spinner">
|
||||
<Spinner animation="border" variant="light" screenReaderText={formatMessage(messages.loading)} />
|
||||
</div>
|
||||
) : null}
|
||||
{status === 'error' && (
|
||||
<Alert className="mt-4" variant="danger" data-testid="courseware-search-error">
|
||||
{formatMessage(messages.searchResultsError)}
|
||||
</Alert>
|
||||
)}
|
||||
{status === 'results' ? (
|
||||
<>
|
||||
{total > 0 ? (
|
||||
<div
|
||||
className="courseware-search__results-summary"
|
||||
aria-relevant="all"
|
||||
aria-atomic="true"
|
||||
data-testid="courseware-search-summary"
|
||||
>{formatMessage(messages.searchResultsLabel, { total, keyword: lastSearchKeyword })}
|
||||
</div>
|
||||
) : null}
|
||||
<CoursewareSearchResultsFilterContainer />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursewareSearch;
|
||||
287
src/course-home/courseware-search/CoursewareSearch.test.jsx
Normal file
287
src/course-home/courseware-search/CoursewareSearch.test.jsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import React from 'react';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
within,
|
||||
} from '../../setupTest';
|
||||
import { CoursewareSearch } from './index';
|
||||
import { useElementBoundingBox, useLockScroll, useCoursewareSearchParams } from './hooks';
|
||||
import initializeStore from '../../store';
|
||||
import { searchCourseContent } from '../data/thunks';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
import { updateModel, useModel } from '../../generic/model-store';
|
||||
|
||||
jest.mock('./hooks');
|
||||
jest.mock('../../generic/model-store', () => ({
|
||||
...jest.requireActual('../../generic/model-store'),
|
||||
updateModel: jest.fn(),
|
||||
useModel: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics', () => ({
|
||||
sendTrackingLogEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../data/thunks', () => ({
|
||||
searchCourseContent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../data/slice', () => ({
|
||||
setShowSearch: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const decodedSequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
|
||||
const decodedUnitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
|
||||
const pathname = `/course/${decodedCourseId}/${decodedSequenceId}/${decodedUnitId}`;
|
||||
|
||||
const tabsTopPosition = 128;
|
||||
|
||||
const defaultProps = {
|
||||
org: 'edX',
|
||||
loading: false,
|
||||
searchKeyword: '',
|
||||
errors: undefined,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const defaultSearchParams = {
|
||||
query: '',
|
||||
filter: '',
|
||||
setQuery: jest.fn(),
|
||||
setFilter: jest.fn(),
|
||||
clearSearchParams: jest.fn(),
|
||||
};
|
||||
|
||||
const intl = {
|
||||
formatMessage: (message) => message?.defaultMessage || '',
|
||||
};
|
||||
|
||||
function renderComponent(props = {}) {
|
||||
const store = initializeStore();
|
||||
history.push(pathname);
|
||||
const { container } = render(
|
||||
<AppProvider store={store}>
|
||||
<Routes>
|
||||
<Route path="/course/:courseId/:sequenceId/:unitId" element={<CoursewareSearch intl={intl} {...props} />} />
|
||||
</Routes>
|
||||
</AppProvider>,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
const mockModels = ((props = defaultProps) => {
|
||||
useModel.mockReturnValue({
|
||||
...defaultProps,
|
||||
...props,
|
||||
});
|
||||
|
||||
updateModel.mockReturnValue({
|
||||
type: 'MOCK_ACTION',
|
||||
payload: {
|
||||
modelType: 'contentSearchResults',
|
||||
model: defaultProps,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const mockSearchParams = ((params) => {
|
||||
const props = { ...defaultSearchParams, ...params };
|
||||
useCoursewareSearchParams.mockReturnValue(props);
|
||||
});
|
||||
|
||||
describe('CoursewareSearch', () => {
|
||||
beforeAll(() => initializeMockApp());
|
||||
|
||||
beforeEach(() => {
|
||||
mockModels();
|
||||
mockSearchParams();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when rendering normally', () => {
|
||||
beforeAll(() => {
|
||||
useElementBoundingBox.mockImplementation(() => ({ top: tabsTopPosition }));
|
||||
});
|
||||
|
||||
it('should use useElementBoundingBox() and useLockScroll() hooks', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(useElementBoundingBox).toHaveBeenCalledTimes(1);
|
||||
expect(useLockScroll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => {
|
||||
renderComponent();
|
||||
|
||||
const section = screen.getByTestId('courseware-search-dialog');
|
||||
expect(section.style.getPropertyValue('--modal-top-position')).toBe(`${tabsTopPosition}px`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when clicking on the "Close" button', () => {
|
||||
it('should close the dialog', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
const close = screen.queryByTestId('courseware-search-close-button');
|
||||
fireEvent.click(close);
|
||||
});
|
||||
|
||||
expect(HTMLDialogElement.prototype.close).toHaveBeenCalled();
|
||||
expect(setShowSearch).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when CourseTabsNavigation is not present', () => {
|
||||
it('should use "--modal-top-position: 0" if nce element is not present', () => {
|
||||
useElementBoundingBox.mockImplementation(() => undefined);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const section = screen.getByTestId('courseware-search-dialog');
|
||||
expect(section.style.getPropertyValue('--modal-top-position')).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when passing extra props', () => {
|
||||
it('should pass on extra props to section element', () => {
|
||||
renderComponent({ foo: 'bar' });
|
||||
|
||||
const section = screen.getByTestId('courseware-search-dialog');
|
||||
expect(section).toHaveAttribute('foo', 'bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when submitting an empty search', () => {
|
||||
it('should clear the search by dispatch updateModel', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
const submit = screen.queryByTestId('courseware-search-form-submit');
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
expect(updateModel).toHaveBeenCalledWith({
|
||||
modelType: 'contentSearchResults',
|
||||
model: {
|
||||
id: decodedCourseId,
|
||||
searchKeyword: '',
|
||||
results: [],
|
||||
errors: undefined,
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when submitting a search', () => {
|
||||
it('should show a loading state', () => {
|
||||
mockModels({
|
||||
loading: true,
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByTestId('courseware-search-spinner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call searchCourseContent', async () => {
|
||||
renderComponent();
|
||||
|
||||
const searchKeyword = 'course';
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.queryByTestId('courseware-search-form').querySelector('input');
|
||||
fireEvent.change(input, { target: { value: searchKeyword } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const submit = screen.queryByTestId('courseware-search-form-submit');
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.course.home.courseware_search.submit', {
|
||||
org_key: defaultProps.org,
|
||||
courserun_key: decodedCourseId,
|
||||
event_type: 'searchKeyword',
|
||||
keyword: searchKeyword,
|
||||
});
|
||||
expect(searchCourseContent).toHaveBeenCalledWith(decodedCourseId, searchKeyword);
|
||||
});
|
||||
|
||||
it('should show an error state if any', () => {
|
||||
mockModels({
|
||||
errors: ['foo'],
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByTestId('courseware-search-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show a summary if there are no results', () => {
|
||||
mockModels({
|
||||
searchKeyword: 'test',
|
||||
total: 0,
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByTestId('courseware-search-summary')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show a summary for the results within a container with aria-live="polite"', () => {
|
||||
mockModels({
|
||||
searchKeyword: 'fubar',
|
||||
total: 1,
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
const results = screen.queryByTestId('courseware-search-results');
|
||||
|
||||
expect(results).toHaveAttribute('aria-live', 'polite');
|
||||
expect(within(results).queryByTestId('courseware-search-summary').textContent).toBe('Results for "fubar":');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when clearing the search input', () => {
|
||||
it('should clear the search by dispatch updateModel', async () => {
|
||||
mockSearchParams({ query: 'fubar' });
|
||||
mockModels({
|
||||
searchKeyword: 'fubar',
|
||||
total: 2,
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.queryByTestId('courseware-search-form').querySelector('input');
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
});
|
||||
|
||||
expect(updateModel).toHaveBeenCalledWith({
|
||||
modelType: 'contentSearchResults',
|
||||
model: {
|
||||
id: decodedCourseId,
|
||||
searchKeyword: '',
|
||||
results: [],
|
||||
errors: undefined,
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
14
src/course-home/courseware-search/CoursewareSearchEmpty.jsx
Normal file
14
src/course-home/courseware-search/CoursewareSearchEmpty.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
const CoursewareSearchEmpty = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<div className="courseware-search-results">
|
||||
<p className="courseware-search-results__empty" data-testid="no-results">{intl.formatMessage(messages.searchResultsNone)}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursewareSearchEmpty;
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
} from '../../setupTest';
|
||||
import CoursewareSearchEmpty from './CoursewareSearchEmpty';
|
||||
import messages from './messages';
|
||||
|
||||
function renderComponent() {
|
||||
const { container } = render(<CoursewareSearchEmpty />);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearchEmpty', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
it('render empty results text and corresponding classes', () => {
|
||||
renderComponent();
|
||||
const emptyText = screen.getByText(messages.searchResultsNone.defaultMessage);
|
||||
expect(emptyText).toBeInTheDocument();
|
||||
expect(emptyText).toHaveClass('courseware-search-results__empty');
|
||||
expect(emptyText).toHaveAttribute('data-testid', 'no-results');
|
||||
expect(emptyText.parentElement).toHaveClass('courseware-search-results');
|
||||
});
|
||||
});
|
||||
55
src/course-home/courseware-search/CoursewareSearchForm.jsx
Normal file
55
src/course-home/courseware-search/CoursewareSearchForm.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { SearchField } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
const CoursewareSearchForm = ({
|
||||
searchTerm,
|
||||
onSubmit,
|
||||
onChange,
|
||||
placeholder,
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<SearchField.Advanced
|
||||
value={searchTerm}
|
||||
onSubmit={onSubmit}
|
||||
onChange={onChange}
|
||||
submitButtonLocation="external"
|
||||
className="courseware-search-form"
|
||||
screenReaderText={{
|
||||
label: formatMessage(messages.searchSubmitLabel),
|
||||
clearButton: formatMessage(messages.searchClearAction),
|
||||
submitButton: null, // Remove the sr-only label in the button.
|
||||
}}
|
||||
>
|
||||
<div className="pgn__searchfield_wrapper" data-testid="courseware-search-form">
|
||||
<SearchField.Label />
|
||||
<SearchField.Input placeholder={placeholder} autoFocus />
|
||||
<SearchField.ClearButton />
|
||||
</div>
|
||||
<SearchField.SubmitButton
|
||||
buttonText={formatMessage(messages.searchSubmitLabel)}
|
||||
submitButtonLocation="external"
|
||||
data-testid="courseware-search-form-submit"
|
||||
/>
|
||||
</SearchField.Advanced>
|
||||
);
|
||||
};
|
||||
|
||||
CoursewareSearchForm.propTypes = {
|
||||
searchTerm: PropTypes.string,
|
||||
onSubmit: PropTypes.func,
|
||||
onChange: PropTypes.func,
|
||||
placeholder: PropTypes.string,
|
||||
};
|
||||
|
||||
CoursewareSearchForm.defaultProps = {
|
||||
searchTerm: undefined,
|
||||
onSubmit: undefined,
|
||||
onChange: undefined,
|
||||
placeholder: undefined,
|
||||
};
|
||||
|
||||
export default CoursewareSearchForm;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
act,
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from '../../setupTest';
|
||||
import CoursewareSearchForm from './CoursewareSearchForm';
|
||||
|
||||
function renderComponent(placeholder, onSubmit, onChange) {
|
||||
const { container } = render(<CoursewareSearchForm
|
||||
placeholder={placeholder}
|
||||
onSubmit={onSubmit}
|
||||
onChange={onChange}
|
||||
/>);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearchToggle', () => {
|
||||
const placeholderText = 'Search for courseware';
|
||||
let onSubmitHandlerMock;
|
||||
let onChangeHandlerMock;
|
||||
|
||||
beforeAll(async () => {
|
||||
onChangeHandlerMock = jest.fn();
|
||||
onSubmitHandlerMock = jest.fn();
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
it('should render', async () => {
|
||||
await act(async () => renderComponent(placeholderText, onSubmitHandlerMock, onChangeHandlerMock));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('courseware-search-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onChange handler when input changes', async () => {
|
||||
await act(async () => renderComponent(placeholderText, onSubmitHandlerMock, onChangeHandlerMock));
|
||||
await waitFor(() => {
|
||||
const element = screen.queryByPlaceholderText(placeholderText);
|
||||
fireEvent.change(element, { target: { value: 'test' } });
|
||||
expect(onChangeHandlerMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onSubmit handler when submit is clicked', async () => {
|
||||
await act(async () => renderComponent(placeholderText, onSubmitHandlerMock, onChangeHandlerMock));
|
||||
await waitFor(async () => {
|
||||
const element = await screen.findByTestId('courseware-search-form-submit');
|
||||
fireEvent.click(element);
|
||||
expect(onSubmitHandlerMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Folder, TextFields, VideoCamera, Article,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import CoursewareSearchEmpty from './CoursewareSearchEmpty';
|
||||
|
||||
const iconTypeMapping = {
|
||||
text: TextFields,
|
||||
video: VideoCamera,
|
||||
sequence: Folder,
|
||||
other: Article,
|
||||
};
|
||||
|
||||
const defaultIcon = Article;
|
||||
|
||||
const CoursewareSearchResults = ({ results = [] }) => {
|
||||
if (!results?.length) {
|
||||
return <CoursewareSearchEmpty />;
|
||||
}
|
||||
|
||||
const baseUrl = `${getConfig().LMS_BASE_URL}`;
|
||||
|
||||
return (
|
||||
<div className="courseware-search-results" data-testid="search-results">
|
||||
{results.map(({
|
||||
id,
|
||||
title,
|
||||
type,
|
||||
location,
|
||||
url,
|
||||
contentHits,
|
||||
}) => {
|
||||
const key = type.toLowerCase();
|
||||
const icon = iconTypeMapping[key] || defaultIcon;
|
||||
const isExternal = !url.startsWith('/');
|
||||
const linkProps = isExternal ? {
|
||||
href: url,
|
||||
target: '_blank',
|
||||
rel: 'nofollow',
|
||||
} : { href: `${baseUrl}${url}` };
|
||||
|
||||
return (
|
||||
<a key={id} className="courseware-search-results__item" {...linkProps}>
|
||||
<div className="courseware-search-results__icon"><Icon src={icon} /></div>
|
||||
<div className="courseware-search-results__info">
|
||||
<div className="courseware-search-results__title">
|
||||
<span>{title}</span>
|
||||
{contentHits ? (<em>{contentHits}</em>) : null }
|
||||
</div>
|
||||
{location?.length ? (
|
||||
<ul className="courseware-search-results__breadcrumbs">
|
||||
{
|
||||
// This ignore is necessary because the breadcrumb texts might have duplicates.
|
||||
// The breadcrumbs are not expected to change.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
location.map((breadcrumb, i) => (<li key={`${i}:${breadcrumb}`}><div>{breadcrumb}</div></li>))
|
||||
}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CoursewareSearchResults.propTypes = {
|
||||
results: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
location: PropTypes.arrayOf(PropTypes.string),
|
||||
url: PropTypes.string,
|
||||
contentHits: PropTypes.number,
|
||||
})),
|
||||
};
|
||||
|
||||
CoursewareSearchResults.defaultProps = {
|
||||
results: [],
|
||||
};
|
||||
|
||||
export default CoursewareSearchResults;
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
} from '../../setupTest';
|
||||
import CoursewareSearchResults from './CoursewareSearchResults';
|
||||
import messages from './messages';
|
||||
import searchResultsFactory from './test-data/search-results-factory';
|
||||
import * as mock from './test-data/mocked-response.json';
|
||||
|
||||
jest.mock('react-redux');
|
||||
|
||||
function renderComponent({ results }) {
|
||||
const { container } = render(<CoursewareSearchResults results={results} />);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearchResults', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
describe('when an empty array is provided', () => {
|
||||
beforeEach(() => { renderComponent({ results: [] }); });
|
||||
|
||||
it('should render a "no results found" message.', () => {
|
||||
expect(screen.getByTestId('no-results').textContent).toBe(messages.searchResultsNone.defaultMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when list of results is provided', () => {
|
||||
beforeEach(() => {
|
||||
const { results } = searchResultsFactory('course');
|
||||
renderComponent({ results });
|
||||
});
|
||||
|
||||
it('should render complete list', () => {
|
||||
const courses = screen.getAllByRole('link');
|
||||
expect(courses.length).toBe(mock.results.length);
|
||||
});
|
||||
|
||||
it('should render correct link for internal course', () => {
|
||||
const courses = screen.getAllByRole('link');
|
||||
const firstCourse = courses[0];
|
||||
const firstCourseTitle = firstCourse.querySelector('.courseware-search-results__title span');
|
||||
expect(firstCourseTitle.innerHTML).toEqual(mock.results[0].data.content.display_name);
|
||||
expect(firstCourse.href).toContain(mock.results[0].data.url);
|
||||
expect(firstCourse).not.toHaveAttribute('target', '_blank');
|
||||
expect(firstCourse).not.toHaveAttribute('rel', 'nofollow');
|
||||
});
|
||||
|
||||
it('should render correct link if is External url course', () => {
|
||||
const courses = screen.getAllByRole('link');
|
||||
const externalCourse = courses[courses.length - 1];
|
||||
const externalCourseTitle = externalCourse.querySelector('.courseware-search-results__title span');
|
||||
expect(externalCourseTitle.innerHTML).toEqual(mock.results[mock.results.length - 1].data.content.display_name);
|
||||
expect(externalCourse.href).toContain(mock.results[mock.results.length - 1].data.url);
|
||||
expect(externalCourse).toHaveAttribute('target', '_blank');
|
||||
expect(externalCourse).toHaveAttribute('rel', 'nofollow');
|
||||
const icon = externalCourse.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render location breadcrumbs', () => {
|
||||
const breadcrumbs = screen.getAllByText(mock.results[0].data.location[0]);
|
||||
expect(breadcrumbs.length).toBeGreaterThan(0);
|
||||
const firstBreadcrumb = breadcrumbs[0].closest('li');
|
||||
expect(firstBreadcrumb).toBeInTheDocument();
|
||||
expect(firstBreadcrumb.querySelector('div').textContent).toBe(mock.results[0].data.location[0]);
|
||||
expect(firstBreadcrumb.nextSibling.querySelector('div').textContent).toBe(mock.results[0].data.location[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when results are provided with content hits', () => {
|
||||
beforeEach(() => {
|
||||
const { results } = searchResultsFactory('Passing');
|
||||
renderComponent({ results });
|
||||
});
|
||||
|
||||
it('should render content hits', () => {
|
||||
const contentHits = screen.getByText('1');
|
||||
expect(contentHits).toBeInTheDocument();
|
||||
expect(contentHits.tagName).toBe('EM');
|
||||
});
|
||||
});
|
||||
});
|
||||
43
src/course-home/courseware-search/CoursewareSearchToggle.jsx
Normal file
43
src/course-home/courseware-search/CoursewareSearchToggle.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { ManageSearch } from '@openedx/paragon/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import messages from './messages';
|
||||
import { useCoursewareSearchFeatureFlag, useCoursewareSearchParams } from './hooks';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
|
||||
const CoursewareSearchToggle = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const enabled = useCoursewareSearchFeatureFlag();
|
||||
const { query } = useCoursewareSearchParams();
|
||||
|
||||
const handleSearchOpenClick = () => {
|
||||
dispatch(setShowSearch(true));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && !!query) { handleSearchOpenClick(); }
|
||||
}, [enabled]);
|
||||
|
||||
if (!enabled) { return null; }
|
||||
|
||||
return (
|
||||
<div className="courseware-search-toggle">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
className="p-1 mt-2 mr-2"
|
||||
aria-label={intl.formatMessage(messages.searchOpenAction)}
|
||||
onClick={handleSearchOpenClick}
|
||||
data-testid="courseware-search-open-button"
|
||||
iconAfter={ManageSearch}
|
||||
>
|
||||
{intl.formatMessage(messages.contentSearchButton)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursewareSearchToggle;
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '../../setupTest';
|
||||
import { fetchCoursewareSearchSettings } from '../data/thunks';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
import { CoursewareSearchToggle } from './index';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
const mockCoursewareSearchParams = jest.fn();
|
||||
|
||||
jest.mock('../data/thunks');
|
||||
jest.mock('../data/slice');
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
...jest.requireActual('./hooks'),
|
||||
useCoursewareSearchParams: () => mockCoursewareSearchParams,
|
||||
}));
|
||||
|
||||
const coursewareSearch = {
|
||||
query: '',
|
||||
filter: '',
|
||||
setQuery: jest.fn(),
|
||||
setFilter: jest.fn(),
|
||||
clearSearchParams: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSearchParams = ((props = coursewareSearch) => {
|
||||
mockCoursewareSearchParams.mockReturnValue(props);
|
||||
});
|
||||
|
||||
function renderComponent() {
|
||||
const { container } = render(<CoursewareSearchToggle />);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearchToggle', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Should not render when the waffle flag is disabled', async () => {
|
||||
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: false }));
|
||||
mockSearchParams();
|
||||
|
||||
await act(async () => renderComponent());
|
||||
await waitFor(() => {
|
||||
expect(fetchCoursewareSearchSettings).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('courseware-search-open-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Should render when the waffle flag is enabled', async () => {
|
||||
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
|
||||
mockSearchParams();
|
||||
|
||||
await act(async () => renderComponent());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchCoursewareSearchSettings).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('courseware-search-open-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Should dispatch setShowSearch(true) when clicking the search button', async () => {
|
||||
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
|
||||
mockSearchParams();
|
||||
|
||||
await act(async () => renderComponent());
|
||||
const button = await screen.findByTestId('courseware-search-open-button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(setShowSearch).toHaveBeenCalledTimes(1);
|
||||
expect(setShowSearch).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
181
src/course-home/courseware-search/courseware-search.scss
Normal file
181
src/course-home/courseware-search/courseware-search.scss
Normal file
@@ -0,0 +1,181 @@
|
||||
.courseware-search {
|
||||
background: white;
|
||||
position: fixed;
|
||||
top: var(--modal-top-position, 0);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--pgn-color-light-300);
|
||||
z-index: var(--pgn-elevation-modal-zindex); // Bootstrap's z-index layer for Modals.
|
||||
|
||||
&__form {
|
||||
position: relative;
|
||||
|
||||
.h2 {
|
||||
margin-right: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__close {
|
||||
position: absolute !important; // For some reason it gets overridden
|
||||
top: 0;
|
||||
right: 0;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__outer-content {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding-top: 2rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
max-width: 42rem;
|
||||
margin: auto;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__results-summary {
|
||||
font-size: .9rem;
|
||||
color: var(--pgn-color-gray-500);
|
||||
padding: 1rem 0 .5rem;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 20vh;
|
||||
}
|
||||
}
|
||||
|
||||
.courseware-search-results {
|
||||
margin-top: 1.5rem;
|
||||
|
||||
&__empty {
|
||||
color: var(--pgn-color-gray-500);
|
||||
padding: 6rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: block;
|
||||
padding: .75rem 1rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background: var(--pgn-color-light-300);
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
border-top: 1px solid var(--pgn-color-light-300);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
padding: 0.375rem 0 0 0.375rem;
|
||||
color: var(--pgn-color-gray-300);
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 2.5;
|
||||
font-size: 0.875rem;
|
||||
color: var(--pgn-color-black);
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
em {
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-variant-numeric: lining-nums tabular-nums;
|
||||
min-width: 1.25rem;
|
||||
line-height: 1rem;
|
||||
background: var(--pgn-color-light-300);
|
||||
border-radius: 99rem;
|
||||
font-style: normal;
|
||||
margin-left: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__breadcrumbs {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
color: var(--pgn-color-gray-500);
|
||||
overflow: hidden;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
> li {
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
|
||||
&:not(:first-child)::before {
|
||||
content: '›';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -55%);
|
||||
left: -0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.courseware-search-results-tabs {
|
||||
border-bottom-color: var(--pgn-color-gray-400) !important;
|
||||
|
||||
&.nav-tabs .nav-link.active {
|
||||
border-bottom-width: 4px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (--pgn-size-breakpoint-min-width-md) {
|
||||
.courseware-search {
|
||||
&__close {
|
||||
right: -2.5rem;
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding-top: 8rem;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
body._search-no-scroll {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
87
src/course-home/courseware-search/hooks.js
Normal file
87
src/course-home/courseware-search/hooks.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState, useEffect, useLayoutEffect } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { debounce } from 'lodash';
|
||||
import { fetchCoursewareSearchSettings } from '../data/thunks';
|
||||
|
||||
const DEBOUNCE_WAIT = 100; // ms
|
||||
|
||||
export function useCoursewareSearchFeatureFlag() {
|
||||
const { courseId } = useParams();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCoursewareSearchSettings(courseId).then(response => setEnabled(response.enabled));
|
||||
}, [courseId]);
|
||||
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function useCoursewareSearchState() {
|
||||
const enabled = useCoursewareSearchFeatureFlag();
|
||||
const show = useSelector(state => state.courseHome.showSearch);
|
||||
|
||||
return { show: enabled && show };
|
||||
}
|
||||
|
||||
export function useElementBoundingBox(elementId) {
|
||||
const [info, setInfo] = useState(undefined);
|
||||
|
||||
const element = document.getElementById(elementId);
|
||||
|
||||
if (!element) {
|
||||
console.warn(`useElementBoundingBox(): Unable to find element with id='${elementId}' in the document.`); // eslint-disable-line no-console
|
||||
return undefined;
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Handler to call on window resize and scroll
|
||||
function recalculate() {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
setInfo(bounds);
|
||||
}
|
||||
const debouncedRecalculate = debounce(recalculate, DEBOUNCE_WAIT, { leading: true });
|
||||
|
||||
// Add event listener
|
||||
global.addEventListener('resize', debouncedRecalculate);
|
||||
global.addEventListener('scroll', debouncedRecalculate);
|
||||
|
||||
// Call handler right away so state gets updated with initial window size
|
||||
debouncedRecalculate();
|
||||
|
||||
// Remove event listener on cleanup
|
||||
return () => {
|
||||
global.removeEventListener('resize', debouncedRecalculate);
|
||||
global.removeEventListener('scroll', debouncedRecalculate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
export function useLockScroll() {
|
||||
useLayoutEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
document.body.classList.add('_search-no-scroll');
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('_search-no-scroll');
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
const initSearchParams = { q: '', f: '' };
|
||||
export function useCoursewareSearchParams() {
|
||||
const [searchParams, setSearchParams] = useSearchParams(initSearchParams);
|
||||
const clearSearchParams = () => setSearchParams(initSearchParams);
|
||||
|
||||
const query = searchParams.get('q');
|
||||
const filter = searchParams.get('f')?.toLowerCase();
|
||||
|
||||
const setQuery = (q) => setSearchParams((params) => ({ q, f: params.get('f') }));
|
||||
const setFilter = (f) => setSearchParams((params) => ({ q: params.get('q'), f }));
|
||||
|
||||
return {
|
||||
query, filter, setQuery, setFilter, clearSearchParams,
|
||||
};
|
||||
}
|
||||
232
src/course-home/courseware-search/hooks.test.jsx
Normal file
232
src/course-home/courseware-search/hooks.test.jsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { fetchCoursewareSearchSettings } from '../data/thunks';
|
||||
import {
|
||||
useCoursewareSearchFeatureFlag,
|
||||
useCoursewareSearchParams,
|
||||
useCoursewareSearchState,
|
||||
useElementBoundingBox,
|
||||
useLockScroll,
|
||||
} from './hooks';
|
||||
|
||||
jest.mock('react-redux');
|
||||
jest.mock('react-router-dom');
|
||||
jest.mock('../data/thunks');
|
||||
|
||||
describe('CoursewareSearch Hooks', () => {
|
||||
const courses = {
|
||||
123: { enabled: true },
|
||||
456: { enabled: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCoursewareSearchSettings.mockImplementation((courseId) => Promise.resolve(courses[courseId]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('useCoursewareSearchFeatureFlag', () => {
|
||||
const renderTestHook = async (enabled = true) => {
|
||||
useParams.mockImplementation(() => ({ courseId: enabled ? 123 : 456 }));
|
||||
let hook;
|
||||
await act(async () => { (hook = renderHook(() => useCoursewareSearchFeatureFlag())); });
|
||||
return hook;
|
||||
};
|
||||
|
||||
it('should return true if feature is enabled', async () => {
|
||||
const hook = await renderTestHook();
|
||||
await waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
|
||||
expect(hook.result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if feature is disabled', async () => {
|
||||
const hook = await renderTestHook(false);
|
||||
await waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
|
||||
expect(hook.result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCoursewareSearchState', () => {
|
||||
const renderTestHook = async ({ enabled, showSearch }) => {
|
||||
useParams.mockImplementation(() => ({ courseId: enabled ? 123 : 456 }));
|
||||
const mockedStoreState = { courseHome: { showSearch } };
|
||||
useSelector.mockImplementation(selector => selector(mockedStoreState));
|
||||
|
||||
let hook;
|
||||
await act(async () => { (hook = renderHook(() => useCoursewareSearchState())); });
|
||||
return hook;
|
||||
};
|
||||
|
||||
it('should return show: true if feature is enabled and showSearch is true', async () => {
|
||||
const hook = await renderTestHook({ enabled: true, showSearch: true });
|
||||
|
||||
expect(hook.result.current).toEqual({ show: true });
|
||||
});
|
||||
|
||||
it('should return show: false in any other case', async () => {
|
||||
let hook;
|
||||
|
||||
hook = await renderTestHook({ enabled: true, showSearch: false });
|
||||
expect(hook.result.current).toEqual({ show: false });
|
||||
|
||||
hook = await renderTestHook({ enabled: false, showSearch: true });
|
||||
expect(hook.result.current).toEqual({ show: false });
|
||||
|
||||
hook = await renderTestHook({ enabled: false, showSearch: false });
|
||||
expect(hook.result.current).toEqual({ show: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useElementBoundingBox', () => {
|
||||
let getBoundingClientRectSpy;
|
||||
const renderTestHook = async ({ elementId, mockedInfo }) => {
|
||||
getBoundingClientRectSpy = jest.spyOn(document, 'getElementById').mockImplementation(() => (
|
||||
mockedInfo
|
||||
? { getBoundingClientRect: () => ({ ...mockedInfo }) }
|
||||
: undefined
|
||||
));
|
||||
|
||||
let hook;
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useElementBoundingBox(elementId));
|
||||
});
|
||||
|
||||
return hook;
|
||||
};
|
||||
|
||||
let addEventListenerSpy;
|
||||
let removeEventListenerSpy;
|
||||
beforeEach(() => {
|
||||
addEventListenerSpy = jest.spyOn(global, 'addEventListener');
|
||||
removeEventListenerSpy = jest.spyOn(global, 'removeEventListener');
|
||||
});
|
||||
|
||||
describe('when element is present', () => {
|
||||
const mockedInfo = { top: 128 };
|
||||
|
||||
it('should bind resize and scroll events on mount', async () => {
|
||||
await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything());
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything());
|
||||
});
|
||||
|
||||
it('should unbindbind resize and scroll events when unmounted', async () => {
|
||||
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
hook.unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything());
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything());
|
||||
});
|
||||
|
||||
it('should return the element bounding box', async () => {
|
||||
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
|
||||
await waitFor(() => expect(getBoundingClientRectSpy).toHaveBeenCalled());
|
||||
|
||||
expect(hook.result.current).toEqual(mockedInfo);
|
||||
});
|
||||
|
||||
it('should call getBoundingClientRect on window resize', async () => {
|
||||
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
|
||||
act(() => {
|
||||
// Trigger the window resize event.
|
||||
global.innerWidth = 500;
|
||||
global.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
|
||||
expect(hook.result.current).toEqual(mockedInfo);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when element is NOT present', () => {
|
||||
let consoleWarnSpy;
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('should log a warning and return undefined', async () => {
|
||||
await renderTestHook({ elementId: 'happiness' });
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith("useElementBoundingBox(): Unable to find element with id='happiness' in the document.");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLockScroll', () => {
|
||||
const renderTestHook = () => (
|
||||
renderHook(() => useLockScroll())
|
||||
);
|
||||
|
||||
let windowScrollSpy;
|
||||
let addBodyClassSpy;
|
||||
let removeBodyClassSpy;
|
||||
let hook;
|
||||
|
||||
beforeEach(() => {
|
||||
windowScrollSpy = jest.spyOn(window, 'scrollTo');
|
||||
addBodyClassSpy = jest.spyOn(document.body.classList, 'add');
|
||||
removeBodyClassSpy = jest.spyOn(document.body.classList, 'remove');
|
||||
hook = renderTestHook();
|
||||
});
|
||||
|
||||
it('should perform a scrollTo(0, 0) on mount', () => {
|
||||
expect(windowScrollSpy).toHaveBeenCalledWith(0, 0);
|
||||
});
|
||||
|
||||
it('should append a _search-no-scroll on mount to the document body', () => {
|
||||
expect(addBodyClassSpy).toHaveBeenCalledWith('_search-no-scroll');
|
||||
});
|
||||
|
||||
it('should remove the _search-no-scroll on unmount', () => {
|
||||
hook.unmount();
|
||||
|
||||
expect(removeBodyClassSpy).toHaveBeenCalledWith('_search-no-scroll');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSearchParams', () => {
|
||||
const initSearch = { q: '', f: '' };
|
||||
const q = { value: '' };
|
||||
const f = { value: '' };
|
||||
const mockedQuery = { q, f };
|
||||
const searchParams = { get: (prop) => mockedQuery[prop].value };
|
||||
const setSearchParams = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
useSearchParams.mockImplementation(() => [searchParams, setSearchParams]);
|
||||
});
|
||||
|
||||
it('should init the search params properly', () => {
|
||||
const {
|
||||
query, filter, setQuery, setFilter, clearSearchParams,
|
||||
} = useCoursewareSearchParams();
|
||||
|
||||
expect(useSearchParams).toBeCalledWith(initSearch);
|
||||
expect(query).toBe('');
|
||||
expect(filter).toBe('');
|
||||
|
||||
setQuery('setQuery');
|
||||
expect(setSearchParams).toBeCalledWith(expect.any(Function));
|
||||
|
||||
setFilter('setFilter');
|
||||
expect(setSearchParams).toBeCalledWith(expect.any(Function));
|
||||
|
||||
clearSearchParams();
|
||||
expect(setSearchParams).toBeCalledWith(initSearch);
|
||||
});
|
||||
|
||||
it('should return the query and lowercase filter if any', () => {
|
||||
q.value = '42';
|
||||
f.value = 'LOWERCASE';
|
||||
const { query, filter } = useCoursewareSearchParams();
|
||||
|
||||
expect(query).toBe('42');
|
||||
expect(filter).toBe('lowercase');
|
||||
});
|
||||
});
|
||||
});
|
||||
3
src/course-home/courseware-search/index.js
Normal file
3
src/course-home/courseware-search/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as CoursewareSearchToggle } from './CoursewareSearchToggle';
|
||||
export { default as CoursewareSearch } from './CoursewareSearch';
|
||||
117
src/course-home/courseware-search/map-search-response.js
Normal file
117
src/course-home/courseware-search/map-search-response.js
Normal file
@@ -0,0 +1,117 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
const endpointSchema = Joi.object({
|
||||
took: Joi.number().required(),
|
||||
total: Joi.number().required(),
|
||||
maxScore: Joi.number().allow(null),
|
||||
results: Joi.array().items(Joi.object({
|
||||
id: Joi.string(),
|
||||
contentType: Joi.string(),
|
||||
location: Joi.array().items(Joi.string()),
|
||||
url: Joi.string(),
|
||||
content: Joi.object({
|
||||
displayName: Joi.string(),
|
||||
htmlContent: Joi.string(),
|
||||
transcriptEn: Joi.string(),
|
||||
}),
|
||||
}).unknown(true)).strict(),
|
||||
}).unknown(true).strict();
|
||||
|
||||
const defaultType = 'text';
|
||||
|
||||
// Parses the search results in a convenient way.
|
||||
export default function mapSearchResponse(response, searchKeywords = '') {
|
||||
const { error, value: data } = endpointSchema.validate(response);
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error in server response:', error);
|
||||
}
|
||||
|
||||
const keywords = searchKeywords ? searchKeywords.toLowerCase().split(' ') : [];
|
||||
|
||||
const {
|
||||
took: ms,
|
||||
total,
|
||||
maxScore,
|
||||
results: rawResults,
|
||||
} = data;
|
||||
|
||||
const results = rawResults.map(result => {
|
||||
const {
|
||||
score,
|
||||
data: {
|
||||
id,
|
||||
content: {
|
||||
displayName,
|
||||
htmlContent,
|
||||
transcriptEn,
|
||||
},
|
||||
contentType,
|
||||
location,
|
||||
url,
|
||||
},
|
||||
} = result;
|
||||
|
||||
const type = contentType?.toLowerCase() || defaultType;
|
||||
|
||||
const content = htmlContent || transcriptEn || '';
|
||||
const searchContent = content.toLowerCase();
|
||||
let contentHits = 0;
|
||||
if (keywords.length) {
|
||||
keywords.forEach(word => {
|
||||
contentHits += searchContent ? searchContent.toLowerCase().split(word).length - 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
const title = displayName || contentType;
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
type,
|
||||
location,
|
||||
url,
|
||||
contentHits,
|
||||
score,
|
||||
};
|
||||
});
|
||||
|
||||
const filters = rawResults.reduce((list, result) => {
|
||||
const label = result?.data?.contentType;
|
||||
|
||||
if (!label) { return list; }
|
||||
|
||||
const key = label.toLowerCase();
|
||||
|
||||
const index = list.findIndex(i => i.key === key);
|
||||
|
||||
if (index === -1) {
|
||||
return [
|
||||
...list,
|
||||
{
|
||||
key,
|
||||
label,
|
||||
count: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const newItem = { ...list[index] };
|
||||
newItem.count++;
|
||||
|
||||
const newList = list.slice(0);
|
||||
newList[index] = newItem;
|
||||
|
||||
return newList;
|
||||
}, []);
|
||||
|
||||
filters.sort((a, b) => (a.key > b.key ? 1 : -1));
|
||||
|
||||
return {
|
||||
results,
|
||||
filters,
|
||||
total,
|
||||
maxScore,
|
||||
ms,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import mapSearchResponse from './map-search-response';
|
||||
import mockedResponse from './test-data/mocked-response.json';
|
||||
|
||||
describe('mapSearchResponse', () => {
|
||||
describe('when the response is correct', () => {
|
||||
let response;
|
||||
|
||||
beforeEach(() => {
|
||||
response = mapSearchResponse(camelCaseObject(mockedResponse));
|
||||
});
|
||||
|
||||
it('should match number of results', () => {
|
||||
expect(response.results.length).toBe(mockedResponse.results.length);
|
||||
});
|
||||
|
||||
it('should match expected filters', () => {
|
||||
const expectedFilters = [
|
||||
{ key: 'capa', label: 'CAPA', count: 7 },
|
||||
{ key: 'sequence', label: 'Sequence', count: 2 },
|
||||
{ key: 'text', label: 'Text', count: 9 },
|
||||
{ key: 'unknown', label: 'Unknown', count: 1 },
|
||||
{ key: 'video', label: 'Video', count: 2 },
|
||||
];
|
||||
expect(response.filters).toEqual(expectedFilters);
|
||||
});
|
||||
|
||||
it('should match expected results', () => {
|
||||
const mockFirstResult = mockedResponse.results[0];
|
||||
const expectedFirstResult = {
|
||||
id: mockFirstResult.data.id,
|
||||
title: mockFirstResult.data.content.display_name,
|
||||
type: mockFirstResult.data.content_type.toLowerCase(),
|
||||
location: mockFirstResult.data.location,
|
||||
url: mockFirstResult.data.url,
|
||||
contentHits: 0,
|
||||
score: mockFirstResult.score,
|
||||
};
|
||||
expect(response.results[0]).toEqual(expectedFirstResult);
|
||||
});
|
||||
|
||||
it('should match expected ms and max score', () => {
|
||||
expect(response.maxScore).toBe(mockedResponse.max_score);
|
||||
expect(response.ms).toBe(mockedResponse.took);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the a keyword is provided', () => {
|
||||
const searchText = 'Course';
|
||||
|
||||
it('should not count matches title', () => {
|
||||
const response = mapSearchResponse(camelCaseObject(mockedResponse), searchText);
|
||||
expect(response.results[0].contentHits).toBe(0);
|
||||
});
|
||||
|
||||
it('should count matches on content', () => {
|
||||
const response = mapSearchResponse(camelCaseObject(mockedResponse), searchText);
|
||||
expect(response.results[1].contentHits).toBe(1);
|
||||
});
|
||||
|
||||
it('should ignore capitalization', () => {
|
||||
const response = mapSearchResponse(camelCaseObject(mockedResponse), searchText.toUpperCase());
|
||||
expect(response.results[1].contentHits).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the response has a wrong format', () => {
|
||||
it('should throw an error', () => {
|
||||
expect(() => mapSearchResponse({ foo: 'bar' })).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
88
src/course-home/courseware-search/messages.ts
Normal file
88
src/course-home/courseware-search/messages.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
searchOpenAction: {
|
||||
id: 'learn.coursewareSearch.openAction',
|
||||
defaultMessage: 'Search within this course',
|
||||
description: 'Aria-label for a button that will pop up Courseware Search.',
|
||||
},
|
||||
contentSearchButton: {
|
||||
id: 'learn.coursewareSearch.contentSearchButton',
|
||||
defaultMessage: 'Content search',
|
||||
description: 'Text for a button that will pop up Courseware Search.',
|
||||
},
|
||||
searchSubmitLabel: {
|
||||
id: 'learn.coursewareSearch.submitLabel',
|
||||
defaultMessage: 'Search',
|
||||
description: 'Button label that will submit Courseware Search.',
|
||||
},
|
||||
searchClearAction: {
|
||||
id: 'learn.coursewareSearch.clearAction',
|
||||
defaultMessage: 'Clear search',
|
||||
description: 'Button label that will the current Courseware Search input.',
|
||||
},
|
||||
searchCloseAction: {
|
||||
id: 'learn.coursewareSearch.closeAction',
|
||||
defaultMessage: 'Close the search form',
|
||||
description: 'Aria-label for a button that will close Courseware Search.',
|
||||
},
|
||||
searchModuleTitle: {
|
||||
id: 'learn.coursewareSearch.searchModuleTitle',
|
||||
defaultMessage: 'Search this course',
|
||||
description: 'Title for the Courseware Search module.',
|
||||
},
|
||||
searchBarPlaceholderText: {
|
||||
id: 'learn.coursewareSearch.searchBarPlaceholderText',
|
||||
defaultMessage: 'Search',
|
||||
description: 'Placeholder text for the Courseware Search input control',
|
||||
},
|
||||
loading: {
|
||||
id: 'learn.coursewareSearch.loading',
|
||||
defaultMessage: 'Searching...',
|
||||
description: 'Screen reader text to use on the spinner while the search is performing.',
|
||||
},
|
||||
searchResultsNone: {
|
||||
id: 'learn.coursewareSearch.searchResultsNone',
|
||||
defaultMessage: 'No results found.',
|
||||
description: 'Text to show when the Courseware Search found no results matching the criteria.',
|
||||
},
|
||||
searchResultsLabel: {
|
||||
id: 'learn.coursewareSearch.searchResultsLabel',
|
||||
defaultMessage: 'Results for "{keyword}":',
|
||||
description: 'Text to show above the search results response list.',
|
||||
},
|
||||
searchResultsError: {
|
||||
id: 'learn.coursewareSearch.searchResultsError',
|
||||
defaultMessage: 'There was an error on the search process. Please try again in a few minutes. If the problem persists, please contact the support team.',
|
||||
description: 'Error message to show to the users when there\'s an error with the endpoint or the returned payload format.',
|
||||
},
|
||||
|
||||
// These are translations for labeling the filters
|
||||
'filter:all': {
|
||||
id: 'learn.coursewareSearch.filter:all',
|
||||
defaultMessage: 'All content',
|
||||
description: 'Label for the search results filter that shows all content (no filter).',
|
||||
},
|
||||
'filter:text': {
|
||||
id: 'learn.coursewareSearch.filter:text',
|
||||
defaultMessage: 'Text',
|
||||
description: 'Label for the search results filter that shows results with text content.',
|
||||
},
|
||||
'filter:video': {
|
||||
id: 'learn.coursewareSearch.filter:video',
|
||||
defaultMessage: 'Video',
|
||||
description: 'Label for the search results filter that shows results with video content.',
|
||||
},
|
||||
'filter:sequence': {
|
||||
id: 'learn.coursewareSearch.filter:sequence',
|
||||
defaultMessage: 'Section',
|
||||
description: 'Label for the search results filter that shows results with section content.',
|
||||
},
|
||||
'filter:other': {
|
||||
id: 'learn.coursewareSearch.filter:other',
|
||||
defaultMessage: 'Other',
|
||||
description: 'Label for the search results filter that shows results with other content.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
570
src/course-home/courseware-search/test-data/mocked-response.json
Normal file
570
src/course-home/courseware-search/test-data/mocked-response.json
Normal file
@@ -0,0 +1,570 @@
|
||||
{
|
||||
"took": 5,
|
||||
"total": 29,
|
||||
"max_score": 3.4545178,
|
||||
"results": [
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Demo Course Overview"
|
||||
},
|
||||
"content_type": "Sequence",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
|
||||
"start_date": "1970-01-01T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Introduction",
|
||||
"Demo Course Overview"
|
||||
],
|
||||
"excerpt": "Demo <b>Course</b> Overview",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction"
|
||||
},
|
||||
"score": 3.4545178
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Passing a Course",
|
||||
"html_content": "Passing a COurse After the last assignment in a class has been due, you will see the entry in your student profile change to show progress toward generating your certificate. After the certificate generation process has completed, you will be able to download it from your profile page. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Passing a Course"
|
||||
],
|
||||
"excerpt": "Passing a <b>Course</b><span class=\"search-results-ellipsis\"></span>Passing a <b>COurse</b> After the last assignment in a class has been due,",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9"
|
||||
},
|
||||
"score": 3.4545178
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Passing a Course"
|
||||
},
|
||||
"content_type": "Sequence",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Passing a Course"
|
||||
],
|
||||
"excerpt": "Passing a <b>Course</b>",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff"
|
||||
},
|
||||
"score": 3.4545178
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Text Input",
|
||||
"capa_content": " Here's a very simple example of a text input question. Depending on the course you may have to observe special text requirements for dates, case sensitivity, etc. Which country contains Paris as its capital? "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"stringresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Text input"
|
||||
],
|
||||
"excerpt": "the <b>course</b> you may have to observe special text requirements for",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02"
|
||||
},
|
||||
"score": 1.5874016
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Pointing on a Picture",
|
||||
"capa_content": " Some course questions may show you an image and ask that you click on it to answer a question. Try this example. (If you are correct you will see our famous green check mark.) Which animal is a kitten? "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"imageresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Pointing on a Picture"
|
||||
],
|
||||
"excerpt": " Some <b>course</b> questions may show you an image and ask that you click on",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c"
|
||||
},
|
||||
"score": 1.5499392
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Getting Answers",
|
||||
"capa_content": " In some courses a \"show answer\" button might appear below a question. When you click on this button, you can see the correct answer (with an explanation) that would receive full credit. How much does it cost to take an edX course? Enter the number of dollars. "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"numericalresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Getting Answers"
|
||||
],
|
||||
"excerpt": " In some <b>course</b>s a \"show answer\" button might appear below a question.",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4"
|
||||
},
|
||||
"score": 1.5003732
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Welcome!",
|
||||
"transcript_en": " ERIC: Hi, and welcome to the edX demonstration course. I'm Eric, and I'm here to help you get a better understanding of how fun and easy it is to take an edX course. So, let's get started. Let me show you how all the parts work together. If at any time you want to skip this video and get a firsthand experience of the demonstration course, all you have to do is click week one to the left. Don't worry, I won't be offended. Let's first look along the top of the page. This area's called the navigation bar. Click on Courseware to interact with your course. Course Info contains course announcements and updates from the course staff. If your course has digital textbooks, this is where you'll find them. Discussion is where you can communicate with the fellow students on topics and projects, and even occasionally with the course staff. When available, the course Wiki acts as a knowledge base for your course. It's a helpful resource. Clicking on Progress will reveal how well you're doing in your studies and exams. When you take the demo course, we'll provide you with a simple progress report matching your results. Let's look at the left column now. The left side of the Courseware screen contains a course navigation bar starting from the top down. Many courses start with an overview of edX and an introduction to the course. Below the overview are segments of the course, which are released as the course progresses. Typically, an edX course is delivered in week by week segments, and have lessons and homeworks you need to complete. Many courses are 10 to 12 weeks long. We made this demonstration course three weeks for simplicity. Let's now look at the learning sequence. Each item in the left column reveals a corresponding learning sequence. Work your way from left to right. Learning sequences can contain lectures, exercises, and interactive lessons that you can complete on your own schedule. Next, let's discover what makes edX fun and unique, its interactivity. edX prides itself on its interactive lessons, which can include demonstrations, visualizations, and virtual environments. You can try out some in the demo course. Interactive lessons are often graded and contribute to your final grade. While the edX platform also supports more traditional question formats like multiple choice, our classes also test your understanding by allowing you to use labs and simulators, and even asking you to write an essay. Example of these graded interactions are in the demo course. You can see how the questions the course uses for gauging your learning process can even be auto graded, or detailed feedback given in real time. So while an edX course might be rigorous, the tools and visualizations are really fun and truly interactive. Finally, there are many ways successful students like to you interact to get the most out of a course. Beyond the discussion forums, you can meet and engage with fellow classmates through a local meet up-- which we highly recommend-- a Google Hangout, or even invite students to join you via Twitter, Facebook, or other social networks. It's a proven fact that if you engage with others while taking a course, you're more likely to succeed. Now that you've seen how easy it is to take an edX course, experience this demonstration course. Firsthand all you have to do is click on week one to the left and you can get started. "
|
||||
},
|
||||
"content_type": "Video",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
|
||||
"start_date": "1970-01-01T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
"Introduction: Video and Sequences"
|
||||
],
|
||||
"excerpt": " ERIC: Hi, and welcome to the edX demonstration <b>course</b>. I'm Eric, and",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd"
|
||||
},
|
||||
"score": 1.4792063
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Multiple Choice Questions",
|
||||
"capa_content": " Many edX courses have homework or exercises you need to complete. Notice the clock image to the left? That means this homework or exercise needs to be completed for you to pass the course. (This can be a bit confusing; the exercise may or may not have a due date prior to the end of the course.) We\u2019ve provided eight (8) examples of how a professor might ask you questions. While the multiple choice question types below are somewhat standard, explore the other question types in the sequence above, like the formula builder- try them all out. As you go through the question types, notice how edX gives you immediate feedback on your responses - it really helps in the learning process. What color is the open ocean on a sunny day? 'yellow','blue','green' Which piece of furniture is built for sitting? a table a desk a chair a bookshelf Which of the following are musical instruments? a piano a tree a guitar a window "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"multiplechoiceresponse",
|
||||
"choiceresponse",
|
||||
"optionresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Multiple Choice Questions"
|
||||
],
|
||||
"excerpt": " Many edX <b>course</b>s have homework or exercises you need to complete.",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4"
|
||||
},
|
||||
"score": 1.4341705
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Numerical Input",
|
||||
"capa_content": " Some course questions ask that you insert numbers into web-text fields, and your answers can be judged exactly - or approximately - according to the question. Note that the edX system uses a period to indicate decimals, so fifteen and three quarters is written \"15.75\", not \"15,75\". Enter the numerical value of Pi: Enter the approximate value of 502*9: Enter the number of fingernails on a healthy human hand. For the purposes of this question, please consider the thumb as a finger: "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"numericalresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Numerical Input"
|
||||
],
|
||||
"excerpt": " Some <b>course</b> questions ask that you insert numbers into web-text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974"
|
||||
},
|
||||
"score": 1.2987298
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Connecting a Circuit and a Circuit Diagram",
|
||||
"transcript_en": "SPEAKER 1: What we see here-- a mess. OK? What we have is a voltmeter and an amp meter. The amp meter measures current, the voltmeter measures voltage of course. And we're measuring the voltage across the light bulb-- the lamp. And we're measuring the current into the lamp. PETER: So we've got a voltmeter measuring across the-- volts meter-- across there. And then we've got an amp reader measuring into. SPEAKER 1: Right. Exactly. Now, we're going to connect that to a battery. The three cell battery that you've seen before. And we're going to see, of course, that the light bulb lit up. And the current we measure is 122 milliamperes going into the light bulb. 122 milliamperes. And the voltage across is from the plus to minus, 4.31 volts. OK? Now, we can do another experiment. Notice how the light bulb is lit up and how much it's lit, approximately. Now, I'm going to reverse the battery so that we connect the battery in the opposite polarity. OK. Go ahead, Peter. PETER: You connect it, I will draw. SPEAKER 1: OK, I'm just doing it. PETER: So I'll swap these. SPEAKER 1: Yes, sir. OK. And what we observe is basically the amp meter is measuring 122 milliamperes in the negative direction. So that means it's measuring the current into the light bulb-- because I've not changed the orientation with respect to the light bulb-- of minus 122 milliamperes. And the voltage across the light bulb, from here to here, is minus 4.29 volts. PETER: Sorry, that's a minus? SPEAKER 1: Yes. PETER: So if we look at the power in the first case, 122 milliamps times 4.31 volts, we get 526 milliwatts. SPEAKER 1: Yep. PETER: If we measure the power in this case over here, minus 122 milliamps times minus 4.29 volts, we get approximately the same thing. So I'm going to round it off to, let's say best guess, 524, maybe 23 or something. No less. SPEAKER 1: OK. PETER: So this is equal to that within measurement error. SPEAKER 1: And of course, you see the power is the power going into the light bulb and coming out as light and heat. OK? We have arranged our measurements by having these associated reference directions, so that this is plus and that's minus, and that the current always goes into the terminal that we label with plus. That always means that the power we measure by multiplying these two numbers is the power going into this device. PETER: So this light bulb is dissipating 524 milliwatts. If we were to do the same calculation for the battery, so current would be going to the positive terminal, we would-- SPEAKER 1: Well, you have to measure it then from there to there. PETER: Yeah. Plus, minus. That's what we're doing. So this would be 4.29 volts. The current would still be minus 122 milliamps. The current's moving in a loop, so here is the same as here, but the signs are swapped. That same calculation would give us minus 524 milliwatts. And that's because the battery is outputting power, whereas the light bulb is dissipating power. SPEAKER 1: Think about it as, if we're measuring the power entering the battery, it's minus 524 milliwatts. OK? That's the way to think about it. This always gives you the power entering that element."
|
||||
},
|
||||
"content_type": "Video",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
|
||||
"start_date": "2013-02-05T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"Video Presentation Styles"
|
||||
],
|
||||
"excerpt": "measures voltage of <b>course</b>. And we're measuring the voltage across the",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6"
|
||||
},
|
||||
"score": 1.1870136
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "",
|
||||
"capa_content": " We are searching for the smallest monthly payment such that we can pay off the entire balance of a loan within a year. The following values might be useful when writing your solution Monthly interest rate = (Annual interest rate) / 12 Monthly payment lower bound = Balance / 12 Monthly payment upper bound = (Balance x (1 + Monthly interest rate)12) / 12 The following variables contain values as described below: balance - the outstanding balance on the credit card annualInterestRate - annual interest rate as a decimal Write a program that uses these bounds and bisection search (for more info check out the Wikipedia page on bisection search) to find the smallest monthly payment to the cent such that we can pay off the debt within a year. Note that if you do not use bisection search, your code will not run - your code only has 30 seconds to run on our servers. If you get a message that states \"Your submission could not be graded. Please recheck your submission and try again. If the problem persists, please notify the course staff.\", check to be sure your code doesn't take too long to run. The code you paste into the following box should not specify the values for the variables balance or annualInterestRate - our test code will define those values before testing your submission. monthlyInterestRate = annualInterestRate/12 lowerBound = balance/12 upperBound = (balance * (1+annualInterestRate/12)**12)/12 originalBalance = balance # Keep testing new payment values # until the balance is +/- $0.02 while abs(balance) > .02: # Reset the value of balance to its original value balance = originalBalance # Calculate a new monthly payment value from the bounds payment = (upperBound - lowerBound)/2 + lowerBound # Test if this payment value is sufficient to pay off the # entire balance in 12 months for month in range(12): balance -= payment balance *= 1+monthlyInterestRate # Reset bounds based on the final value of balance if balance > 0: # If the balance is too big, need higher payment # so we increase the lower bound lowerBound = payment else: # If the balance is too small, we need a lower # payment, so we decrease the upper bound upperBound = payment # When the while loop terminates, we know we have # our answer! print(\"Lowest Payment:\", round(payment, 2)) {\"grader\": \"ps02/bisect/grade_bisect.py\"} Note: Depending on where, and how frequently, you round during this function, your answers may be off a few cents in either direction. Try rounding as few times as possible in order to increase the accuracy of your result. Hints Test Cases to test your code with. Be sure to test these on your own machine - and that you get the same output! - before running your code on this webpage! Note: The automated tests are lenient - if your answers are off by a few cents in either direction, your code is OK. Test Cases: Test Case 1: balance = 320000 annualInterestRate = 0.2 Result Your Code Should Generate: ------------------- Lowest Payment: 29157.09 Test Case 2: balance = 999999 annualInterestRate = 0.18 Result Your Code Should Generate: ------------------- Lowest Payment: 90325.07 The autograder says, \"Your submission could not be graded.\" Help! If the autograder gives you the following message: Your submission could not be graded. Please recheck your submission and try again. If the problem persists, please notify the course staff. Don't panic! There are a few things that might be wrong with your code that you should check out. The number one reason this message appears is because your code timed out. You only get 30 seconds of computation time on our servers. If your code times out, you probably have an infinite loop. What to do? The number 1 thing to do is that you need to run this code in your own local environment. Your code should print one line at the end of the loop. If your code never prints anything out - you have an infinite loop! To debug your infinite loop - check your loop conditional. When will it stop? Try inserting print statements inside your loop that prints out information (like variables) - are you incrementing or decrementing your loop counter correctly? Search the forum for people with similar issues. If your search turns up nothing, make a new post and paste in your loop conditional for others to help you out with. Please don't email the course staff unless your code legitimately works and prints out the correct answers in your local environment. In that case, please email your code file, a screenshot of the code printing out the correct answers in your local environment, and a screenshot of the exact same code not working on the tutor. The course staff is otherwise unable to help debug your problem set via email - we can only address platform issues. "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"coderesponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 2: Get Interactive",
|
||||
"Homework - Labs and Demos",
|
||||
"Code Grader"
|
||||
],
|
||||
"excerpt": "notify the <b>course</b> staff.\", check to be sure your code doesn't take too",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader"
|
||||
},
|
||||
"score": 1.0107487
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Interactive Questions",
|
||||
"capa_content": " Most courses have interactive questions that test your knowledge like the one below. They can be part of a learning sequence or an exam. Notice the visual feedback. Go ahead, try it out! Questions which are part of assignments or exams may have due dates - the last possible time you can submit an assignment for grading. Once this time has passed, you will not be able to get credit for any incomplete problems in the assignment. If an assignment has a due date, you can see the due date in the sidebar. (This demo course does not have any assignments with due dates.) If no due date is displayed, the assignment can be turned in at any time. All assignment due dates are displayed in the time zone that you select in your account settings. If you do not specify a time zone, assignment due dates display in your browser's time zone. What kinds of late policies does edX allow? late penalties instructor forgiveness late time budget none of the above "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"choiceresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
|
||||
"start_date": "2013-02-05T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"Interactive Questions"
|
||||
],
|
||||
"excerpt": " Most <b>course</b>s have interactive questions that test your knowledge like",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618"
|
||||
},
|
||||
"score": 0.96387196
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Blank HTML Page",
|
||||
"html_content": "Welcome to the Open edX Demo Course Introduction. This is where you can explore how to take an edX course (like this one). Most courses have an \"intro\" video that shows you how it all works. You can watch the introduction video (below) or scroll though the course studies and assignments using the toolbar (above). Just for fun, we'll keep track of your work in this demo course, and show you your progress in the toolbar just like in a real course. Watch the overview video (below), then click on \"Example Week One\" in the left hand navigation to get started. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
|
||||
"start_date": "1970-01-01T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
"Introduction: Video and Sequences"
|
||||
],
|
||||
"excerpt": "Welcome to the Open edX Demo <b>Course</b> Introduction. This is where you",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4"
|
||||
},
|
||||
"score": 0.8844358
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Discussion Forums",
|
||||
"html_content": "Discussion FORUMS The discussion forum for each course is found at the top of the course page. You might come across a subset of the discussion forum inside the course (see below), where you can talk with fellow students about the course in context. Go ahead and be social! Make your first post in this demo course. Keep an eye out for posts with a green check mark. The green check means the post has been recognized by a staff member or forum moderator as a great post. You can also actively upvote a post. Others can search on user \u201cupvoted\u201d posts. They tend to be very helpful. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
|
||||
"start_date": "1978-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Discussion Forums"
|
||||
],
|
||||
"excerpt": "Discussion FORUMS The discussion forum for each <b>course</b> is found at the",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7"
|
||||
},
|
||||
"score": 0.8803684
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Overall Grade",
|
||||
"html_content": " OVERALL GRADE PERFORMANCE The progress tab (selectable near the top of each page in your course) shows your performance. Click on it now, and you will see how you're doing in this demo course. The bar chart shows the overall percentage that you have earned on each assignment in the course, and how each of those assignments combine into your overall grade. Further down the page is a detailed breakdown of your score on every graded question in the class. You might notice that some of your assignments on the bar chart show an 'x'. The 'x's indicate the assignments that the edX system will NOT be counting toward your final grade, according to the course grading. The 'x's go to the assignments that you scored the lowest on. Each course has its own percentage cutoff for a Certificate of Mastery. You can see where those cutoffs are by looking at the vertical description. In this demo, a \"pass\" is considered 60%. When you \"pass\" a live edX course, you will receive a certificate after the class has closed. Sorry - the demo course does not grant certificates! "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Overall Grade Performance"
|
||||
],
|
||||
"excerpt": "of each page in your <b>course</b>) shows your performance. Click on it now,",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c"
|
||||
},
|
||||
"score": 0.87981963
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Blank HTML Page",
|
||||
"html_content": "Find Your Study Buddy Working with other students offline can help you get the most out of an online course and even increase the likelihood you will successfully complete the course. So, your homework is to find a study buddy. The course specific discussion forums are a great place to find neighbors or even new friends to invite to a Meetup you are looking to organize or even a virtual Google Hangout. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
|
||||
"start_date": "1978-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Homework - Find Your Study Buddy"
|
||||
],
|
||||
"excerpt": "get the most out of an online <b>course</b> and even increase the likelihood",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339"
|
||||
},
|
||||
"score": 0.84284115
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Find Your Study Buddy",
|
||||
"html_content": "Find Your Study Buddy Working with other students offline can help you get the most out of an online course and even increase the likelihood you will successfully complete the course. So, your homework is to find a study buddy. The course specific discussion forums are a great place to find neighbors or even new friends to invite to a Meetup you are looking to organize or even a virtual Google Hangout. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
|
||||
"start_date": "2013-02-05T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Homework - Find Your Study Buddy",
|
||||
"Homework - Find Your Study Buddy"
|
||||
],
|
||||
"excerpt": "get the most out of an online <b>course</b> and even increase the likelihood",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5"
|
||||
},
|
||||
"score": 0.84284115
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Be Social",
|
||||
"html_content": "Be SOCIAL A big part of learning online includes \u201cbeing social.\u201d We encourage all students to communicate within the course discussion forums \u2013 a great place to connect with other students and to get support from the course staff. Some students and professors also engage through other social mediums like Meetup or Facebook. Recent research has found that if you take a class with a friend, or engage socially with other learners while taking a course, there is a higher likelihood that you will complete a course. If you haven\u2019t already, consider finding a study buddy! Check out more information about the discussion forum by navigating to the next item in this learning sequence. In the discussion forums, remember to be polite and respectful. Simply put, treat others the way you want to be treated. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
|
||||
"start_date": "1978-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Be Social"
|
||||
],
|
||||
"excerpt": "encourage all students to communicate within the <b>course</b> discussion",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0"
|
||||
},
|
||||
"score": 0.84210813
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "EdX Exams",
|
||||
"html_content": " EDX EXAMS Not all edX courses have exams; many do, but not all. When choosing a course, it's a good idea to check the exam and study requirements, as well as any prerequisites. Of course - you can \"audit\" any edX course, which means you can study alongside other students using the same content, tools and materials, but you're not focused on grades and might skip the exams and assignments. Follow this learning sequence via the links above to understand more about how we grade your work and track your progress. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"EdX Exams"
|
||||
],
|
||||
"excerpt": " EDX EXAMS Not all edX <b>course</b>s have exams; many do, but not all. When",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530"
|
||||
},
|
||||
"score": 0.8306555
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "When Are Your Exams? ",
|
||||
"html_content": "WHEN ARE YOUR Exams? Every course treats the timing on its exams differently, and you should be really careful to pay attention to any announcements about exam timing that your course makes. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
|
||||
"start_date": "2013-02-05T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"When Are Your Exams? "
|
||||
],
|
||||
"excerpt": "WHEN ARE YOUR Exams? Every <b>course</b> treats the timing on its exams",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf"
|
||||
},
|
||||
"score": 0.82610154
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "some-external-reference",
|
||||
"data": {
|
||||
"course": "External Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "External Course Link Test",
|
||||
"html_content": "This should open a new tab when following the link."
|
||||
},
|
||||
"content_type": "Unknown",
|
||||
"id": "random-element-id",
|
||||
"start_date": "2013-02-05T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": null,
|
||||
"excerpt": "Just testing external links",
|
||||
"url": "https://www.edx.org"
|
||||
},
|
||||
"score": 0.82610154
|
||||
}
|
||||
],
|
||||
"access_denied_count": 0
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import mockedData from './mocked-response.json';
|
||||
import mapSearchResponse from '../map-search-response';
|
||||
|
||||
function searchResultsFactory(searchKeywords = '', moreInfo = {}) {
|
||||
const data = camelCaseObject(mockedData);
|
||||
const info = mapSearchResponse(data, searchKeywords);
|
||||
|
||||
const result = {
|
||||
...info,
|
||||
...moreInfo,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default searchResultsFactory;
|
||||
@@ -6,6 +6,7 @@ Factory.define('courseHomeMetadata')
|
||||
.option('host', 'http://localhost:18000')
|
||||
.attrs({
|
||||
title: 'Demonstration Course',
|
||||
is_new_discussion_sidebar_view_enabled: false,
|
||||
is_self_paced: false,
|
||||
is_enrolled: false,
|
||||
is_staff: false,
|
||||
|
||||
@@ -31,7 +31,6 @@ Factory.define('outlineTabData')
|
||||
course_access_redirect: false,
|
||||
has_scheduled_content: null,
|
||||
access_expiration: null,
|
||||
can_show_upgrade_sock: false,
|
||||
cert_data: {
|
||||
cert_status: null,
|
||||
cert_web_view_url: null,
|
||||
|
||||
@@ -17,7 +17,21 @@ Factory.define('progressTabData')
|
||||
percent: 1,
|
||||
is_passing: true,
|
||||
},
|
||||
final_grades: 0.5,
|
||||
credit_course_requirements: null,
|
||||
assignment_type_grade_summary: [
|
||||
{
|
||||
type: 'Homework',
|
||||
short_label: 'HW',
|
||||
weight: 1,
|
||||
average_grade: 1,
|
||||
weighted_grade: 1,
|
||||
num_droppable: 1,
|
||||
num_total: 2,
|
||||
has_hidden_contribution: 'none',
|
||||
last_grade_publish_date: null,
|
||||
},
|
||||
],
|
||||
section_scores: [
|
||||
{
|
||||
display_name: 'First section',
|
||||
|
||||
@@ -1,726 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
"hasAccess": true,
|
||||
"userFragment": null,
|
||||
"userMessage": null,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"isEnrolled": false,
|
||||
"isMasquerading": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
},
|
||||
Object {
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
|
||||
},
|
||||
Object {
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
|
||||
},
|
||||
Object {
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
|
||||
},
|
||||
Object {
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"dates": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"courseDateBlocks": Array [
|
||||
Object {
|
||||
"date": "2020-05-01T17:59:41Z",
|
||||
"dateType": "course-start-date",
|
||||
"description": "",
|
||||
"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,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
"hasEnded": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"learnerIsFullAccess": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
"showNewUserCourseHomeTour": false,
|
||||
"toursEnabled": false,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
"hasAccess": true,
|
||||
"userFragment": null,
|
||||
"userMessage": null,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"isEnrolled": false,
|
||||
"isMasquerading": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
},
|
||||
Object {
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
|
||||
},
|
||||
Object {
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
|
||||
},
|
||||
Object {
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
|
||||
},
|
||||
Object {
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"outline": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"accessExpiration": null,
|
||||
"canShowUpgradeSock": false,
|
||||
"certData": Object {
|
||||
"certStatus": null,
|
||||
"certWebViewUrl": null,
|
||||
"certificateAvailableDate": null,
|
||||
},
|
||||
"courseBlocks": Object {
|
||||
"courses": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"sectionIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
],
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
},
|
||||
},
|
||||
"sections": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
|
||||
"complete": false,
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"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",
|
||||
},
|
||||
},
|
||||
"sequences": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
|
||||
"complete": false,
|
||||
"description": null,
|
||||
"due": null,
|
||||
"effortActivities": 2,
|
||||
"effortTime": 15,
|
||||
"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": "Title of Sequence",
|
||||
},
|
||||
},
|
||||
},
|
||||
"courseGoals": Object {
|
||||
"daysPerWeek": null,
|
||||
"goalOptions": Array [],
|
||||
"selectedGoal": null,
|
||||
"subscribedToReminders": null,
|
||||
"weeklyLearningGoalEnabled": false,
|
||||
},
|
||||
"courseTools": Array [
|
||||
Object {
|
||||
"analyticsId": "edx.bookmarks",
|
||||
"title": "Bookmarks",
|
||||
"url": "https://example.com/bookmarks",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": Object {
|
||||
"contentTypeGatingEnabled": false,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
},
|
||||
"datesWidget": Object {
|
||||
"courseDateBlocks": Array [],
|
||||
},
|
||||
"enableProctoredExams": undefined,
|
||||
"enrollAlert": Object {
|
||||
"canEnroll": true,
|
||||
"extraText": "Contact the administrator.",
|
||||
},
|
||||
"enrollmentMode": undefined,
|
||||
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
|
||||
"hasEnded": undefined,
|
||||
"hasScheduledContent": null,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"offer": null,
|
||||
"resumeCourse": Object {
|
||||
"hasVisitedCourse": false,
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
|
||||
},
|
||||
"timeOffsetMillis": 0,
|
||||
"userHasPassingGrade": undefined,
|
||||
"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>",
|
||||
},
|
||||
},
|
||||
},
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
"showNewUserCourseHomeTour": false,
|
||||
"toursEnabled": false,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchProgressTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseStatus": "loading",
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
"hasAccess": true,
|
||||
"userFragment": null,
|
||||
"userMessage": null,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"isEnrolled": false,
|
||||
"isMasquerading": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
},
|
||||
Object {
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
|
||||
},
|
||||
Object {
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
|
||||
},
|
||||
Object {
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
|
||||
},
|
||||
Object {
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"progress": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"accessExpiration": null,
|
||||
"certificateData": Object {},
|
||||
"completionSummary": Object {
|
||||
"completeCount": 1,
|
||||
"incompleteCount": 1,
|
||||
"lockedCount": 0,
|
||||
},
|
||||
"courseGrade": Object {
|
||||
"isPassing": true,
|
||||
"letterGrade": "pass",
|
||||
"percent": 1,
|
||||
},
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"creditCourseRequirements": null,
|
||||
"end": "3027-03-31T00:00:00Z",
|
||||
"enrollmentMode": "audit",
|
||||
"gradesFeatureIsFullyLocked": false,
|
||||
"gradesFeatureIsPartiallyLocked": false,
|
||||
"gradingPolicy": Object {
|
||||
"assignmentPolicies": Array [
|
||||
Object {
|
||||
"averageGrade": "1.00",
|
||||
"numDroppable": 1,
|
||||
"shortLabel": "HW",
|
||||
"type": "Homework",
|
||||
"weight": 1,
|
||||
"weightedGrade": 1,
|
||||
},
|
||||
],
|
||||
"gradeRange": Object {
|
||||
"pass": 0.75,
|
||||
},
|
||||
},
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"sectionScores": Array [
|
||||
Object {
|
||||
"displayName": "First section",
|
||||
"subsections": Array [
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
|
||||
"displayName": "First subsection",
|
||||
"hasGradedAssignment": true,
|
||||
"learnerHasAccess": true,
|
||||
"numPointsEarned": 0,
|
||||
"numPointsPossible": 3,
|
||||
"percentGraded": 0,
|
||||
"problemScores": Array [
|
||||
Object {
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
Object {
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
Object {
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
],
|
||||
"showCorrectness": "always",
|
||||
"showGrades": true,
|
||||
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection",
|
||||
},
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"displayName": "Second section",
|
||||
"subsections": Array [
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"displayName": "Second subsection",
|
||||
"hasGradedAssignment": true,
|
||||
"numPointsEarned": 1,
|
||||
"numPointsPossible": 1,
|
||||
"percentGraded": 1,
|
||||
"problemScores": Array [
|
||||
Object {
|
||||
"earned": 1,
|
||||
"possible": 1,
|
||||
},
|
||||
],
|
||||
"showCorrectness": "always",
|
||||
"showGrades": true,
|
||||
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"studioUrl": "http://studio.edx.org/settings/grading/course-v1:edX+Test+run",
|
||||
"userHasPassingGrade": false,
|
||||
"verificationData": Object {
|
||||
"link": null,
|
||||
"status": "none",
|
||||
"statusDate": null,
|
||||
},
|
||||
"verifiedMode": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
"showNewUserCourseHomeTour": false,
|
||||
"toursEnabled": false,
|
||||
},
|
||||
}
|
||||
`;
|
||||
@@ -3,93 +3,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { logInfo } from '@edx/frontend-platform/logging';
|
||||
import { appendBrowserTimezoneToUrl } from '../../utils';
|
||||
|
||||
const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => {
|
||||
let dropCount = numDroppable;
|
||||
// Drop the lowest grades
|
||||
while (dropCount && points.length >= dropCount) {
|
||||
const lowestScore = Math.min(...points);
|
||||
const lowestScoreIndex = points.indexOf(lowestScore);
|
||||
points.splice(lowestScoreIndex, 1);
|
||||
dropCount--;
|
||||
}
|
||||
let averageGrade = 0;
|
||||
let weightedGrade = 0;
|
||||
if (points.length) {
|
||||
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
|
||||
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
|
||||
// exists in edx-platform.
|
||||
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(2);
|
||||
weightedGrade = averageGrade * assignmentWeight;
|
||||
}
|
||||
return { averageGrade, weightedGrade };
|
||||
};
|
||||
|
||||
function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
|
||||
const gradeByAssignmentType = {};
|
||||
assignmentPolicies.forEach(assignment => {
|
||||
// Create an array with the number of total assignments and set the scores to 0
|
||||
// as placeholders for assignments that have not yet been released
|
||||
gradeByAssignmentType[assignment.type] = {
|
||||
grades: Array(assignment.numTotal).fill(0),
|
||||
numAssignmentsCreated: 0,
|
||||
numTotalExpectedAssignments: assignment.numTotal,
|
||||
};
|
||||
});
|
||||
|
||||
sectionScores.forEach((chapter) => {
|
||||
chapter.subsections.forEach((subsection) => {
|
||||
if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
assignmentType,
|
||||
numPointsEarned,
|
||||
numPointsPossible,
|
||||
} = subsection;
|
||||
|
||||
// If a subsection's assignment type does not match an assignment policy in Studio,
|
||||
// we won't be able to include it in this accumulation of grades by assignment type.
|
||||
// This may happen if a course author has removed/renamed an assignment policy in Studio and
|
||||
// neglected to update the subsection's of that assignment type
|
||||
if (!gradeByAssignmentType[assignmentType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
let {
|
||||
numAssignmentsCreated,
|
||||
} = gradeByAssignmentType[assignmentType];
|
||||
|
||||
numAssignmentsCreated++;
|
||||
if (numAssignmentsCreated <= gradeByAssignmentType[assignmentType].numTotalExpectedAssignments) {
|
||||
// Remove a placeholder grade so long as the number of recorded created assignments is less than the number
|
||||
// of expected assignments
|
||||
gradeByAssignmentType[assignmentType].grades.shift();
|
||||
}
|
||||
// Add the graded assignment to the list
|
||||
gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
|
||||
// Record the created assignment
|
||||
gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated;
|
||||
});
|
||||
});
|
||||
|
||||
return assignmentPolicies.map((assignment) => {
|
||||
const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades(
|
||||
gradeByAssignmentType[assignment.type].grades,
|
||||
assignment.weight,
|
||||
assignment.numDroppable,
|
||||
);
|
||||
|
||||
return {
|
||||
averageGrade,
|
||||
numDroppable: assignment.numDroppable,
|
||||
shortLabel: assignment.shortLabel,
|
||||
type: assignment.type,
|
||||
weight: assignment.weight,
|
||||
weightedGrade,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tweak the metadata for consistency
|
||||
* @param metadata the data to normalize
|
||||
@@ -136,6 +49,7 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
title: block.display_name,
|
||||
resumeBlock: block.resume_block,
|
||||
sequenceIds: block.children || [],
|
||||
hideFromTOC: block.hide_from_toc,
|
||||
};
|
||||
break;
|
||||
|
||||
@@ -152,6 +66,8 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
// link in the outline (even though we ignore the given url and use an internal <Link> to ourselves).
|
||||
showLink: !!block.lms_web_url,
|
||||
title: block.display_name,
|
||||
hideFromTOC: block.hide_from_toc,
|
||||
navigationDisabled: block.navigation_disabled,
|
||||
};
|
||||
break;
|
||||
|
||||
@@ -233,11 +149,6 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
const camelCasedData = camelCaseObject(data);
|
||||
|
||||
camelCasedData.gradingPolicy.assignmentPolicies = normalizeAssignmentPolicies(
|
||||
camelCasedData.gradingPolicy.assignmentPolicies,
|
||||
camelCasedData.sectionScores,
|
||||
);
|
||||
|
||||
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
|
||||
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
|
||||
// in order to preserve a course team's desired grade formatting.
|
||||
@@ -286,9 +197,17 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
}
|
||||
|
||||
export async function getProctoringInfoData(courseId, username) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
|
||||
if (username) {
|
||||
url += `&username=${encodeURIComponent(username)}`;
|
||||
let url;
|
||||
if (!getConfig().EXAMS_BASE_URL) {
|
||||
url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
|
||||
if (username) {
|
||||
url += `&username=${encodeURIComponent(username)}`;
|
||||
}
|
||||
} else {
|
||||
url = `${getConfig().EXAMS_BASE_URL}/api/v1/student/course_id/${encodeURIComponent(courseId)}/onboarding`;
|
||||
if (username) {
|
||||
url += `?username=${encodeURIComponent(username)}`;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
@@ -356,7 +275,6 @@ export async function getOutlineTabData(courseId) {
|
||||
} = tabData;
|
||||
|
||||
const accessExpiration = camelCaseObject(data.access_expiration);
|
||||
const canShowUpgradeSock = data.can_show_upgrade_sock;
|
||||
const certData = camelCaseObject(data.cert_data);
|
||||
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
|
||||
const courseGoals = camelCaseObject(data.course_goals);
|
||||
@@ -378,7 +296,6 @@ export async function getOutlineTabData(courseId) {
|
||||
|
||||
return {
|
||||
accessExpiration,
|
||||
canShowUpgradeSock,
|
||||
certData,
|
||||
courseBlocks,
|
||||
courseGoals,
|
||||
@@ -445,3 +362,20 @@ export async function unsubscribeFromCourseGoal(token) {
|
||||
return getAuthenticatedHttpClient().post(url.href)
|
||||
.then(res => camelCaseObject(res));
|
||||
}
|
||||
|
||||
export async function getCoursewareSearchEnabled(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url.href);
|
||||
return { enabled: data.enabled || false };
|
||||
}
|
||||
|
||||
export async function searchCourseContentFromAPI(courseId, searchKeyword, options = {}) {
|
||||
const defaults = { page: 0, limit: 20 };
|
||||
const { page, limit } = { ...defaults, ...options };
|
||||
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/search/${courseId}`);
|
||||
const formData = `search_string=${searchKeyword}&page_size=${limit}&page_index=${page}`;
|
||||
const response = await getAuthenticatedHttpClient().post(url.href, formData);
|
||||
|
||||
return camelCaseObject(response);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ describe('Course Home Service', () => {
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
can_show_upgrade_sock: boolean(false),
|
||||
verified_mode: like({
|
||||
access_expiration_date: null,
|
||||
currency: 'USD',
|
||||
@@ -89,11 +88,11 @@ describe('Course Home Service', () => {
|
||||
}),
|
||||
title: string('Demonstration Course'),
|
||||
username: string('edx'),
|
||||
has_course_author_access: boolean(true),
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalizedTabData = {
|
||||
canShowUpgradeSock: false,
|
||||
verifiedMode: {
|
||||
accessExpirationDate: null,
|
||||
currency: 'USD',
|
||||
@@ -133,6 +132,7 @@ describe('Course Home Service', () => {
|
||||
],
|
||||
title: 'Demonstration Course',
|
||||
username: 'edx',
|
||||
hasCourseAuthorAccess: true,
|
||||
};
|
||||
const response = getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
expect(response).toBeTruthy();
|
||||
|
||||
@@ -55,6 +55,29 @@ describe('Data layer integration tests', () => {
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
it('should result in fetch failed if course metadata call errored', async () => {
|
||||
const datesTabData = Factory.build('datesTabData');
|
||||
const datesUrl = `${datesBaseUrl}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(courseMetadataUrl).networkError();
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
|
||||
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
it('should result in fetch failed if course metadata call errored', async () => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).networkError();
|
||||
|
||||
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
it('Should fetch, normalize, and save metadata', async () => {
|
||||
const datesTabData = Factory.build('datesTabData');
|
||||
|
||||
@@ -67,29 +90,25 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot({
|
||||
expect(state).toEqual(expect.objectContaining({
|
||||
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
|
||||
// to keep track of conversations. This causes snapshots to fail, because this UUID
|
||||
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
|
||||
// to keep track of conversations. This UUID is generated on each run.
|
||||
// Instead, we use an asymmetric matcher here.
|
||||
learningAssistant: expect.objectContaining({
|
||||
conversationId: expect.any(String),
|
||||
}),
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([401, 403, 404])(
|
||||
'should result in fetch denied for expected errors and failed for all others',
|
||||
'should result in fetch denied if course access is denied, regardless of dates API status',
|
||||
async (errorStatus) => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
|
||||
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).reply(errorStatus, {});
|
||||
|
||||
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
|
||||
|
||||
let expectedState = 'failed';
|
||||
if (errorStatus === 401 || errorStatus === 403) {
|
||||
expectedState = 'denied';
|
||||
}
|
||||
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('denied');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -118,29 +137,25 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot({
|
||||
expect(state).toEqual(expect.objectContaining({
|
||||
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
|
||||
// to keep track of conversations. This causes snapshots to fail, because this UUID
|
||||
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
|
||||
// to keep track of conversations. This UUID is generated on each run.
|
||||
// Instead, we use an asymmetric matcher here.
|
||||
learningAssistant: expect.objectContaining({
|
||||
conversationId: expect.any(String),
|
||||
}),
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([401, 403, 404])(
|
||||
'should result in fetch denied for expected errors and failed for all others',
|
||||
'should result in fetch denied if course access is denied, regardless of outline API status',
|
||||
async (errorStatus) => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
|
||||
axiosMock.onGet(outlineUrl).reply(errorStatus, {});
|
||||
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
|
||||
let expectedState = 'failed';
|
||||
if (errorStatus === 403) {
|
||||
expectedState = 'denied';
|
||||
}
|
||||
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
|
||||
expect(store.getState().courseHome.courseStatus).toEqual('denied');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -170,14 +185,14 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot({
|
||||
expect(state).toEqual(expect.objectContaining({
|
||||
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
|
||||
// to keep track of conversations. This causes snapshots to fail, because this UUID
|
||||
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
|
||||
// to keep track of conversations. This UUID is generated on each run.
|
||||
// Instead, we use an asymmetric matcher here.
|
||||
learningAssistant: expect.objectContaining({
|
||||
conversationId: expect.any(String),
|
||||
}),
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
it('Should handle the url including a targetUserId', async () => {
|
||||
@@ -250,4 +265,36 @@ describe('Data layer integration tests', () => {
|
||||
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}"}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test fetchCoursewareSearchSettings', () => {
|
||||
it('Should return enabled as true when enabled', async () => {
|
||||
const apiUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`;
|
||||
axiosMock.onGet(apiUrl).reply(200, { enabled: true });
|
||||
|
||||
const { enabled } = await thunks.fetchCoursewareSearchSettings(courseId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(apiUrl);
|
||||
expect(enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('Should return enabled as false when disabled', async () => {
|
||||
const apiUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`;
|
||||
axiosMock.onGet(apiUrl).reply(200, { enabled: false });
|
||||
|
||||
const { enabled } = await thunks.fetchCoursewareSearchSettings(courseId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(apiUrl);
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('Should return enabled as false on error', async () => {
|
||||
const apiUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`;
|
||||
axiosMock.onGet(apiUrl).networkError();
|
||||
|
||||
const { enabled } = await thunks.fetchCoursewareSearchSettings(courseId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(apiUrl);
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
export const DENIED = 'denied';
|
||||
import {
|
||||
LOADING,
|
||||
LOADED,
|
||||
FAILED,
|
||||
DENIED,
|
||||
} from '@src/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'course-home',
|
||||
@@ -15,6 +17,7 @@ const slice = createSlice({
|
||||
toastBodyText: null,
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
showSearch: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchProctoringInfoResolved: (state) => {
|
||||
@@ -47,6 +50,9 @@ const slice = createSlice({
|
||||
state.toastBodyText = linkText;
|
||||
state.toastHeader = header;
|
||||
},
|
||||
setShowSearch: (state, { payload }) => {
|
||||
state.showSearch = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,6 +63,7 @@ export const {
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
setCallToActionToast,
|
||||
setShowSearch,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
postDismissWelcomeMessage,
|
||||
postRequestCert,
|
||||
getLiveTabIframe,
|
||||
getCoursewareSearchEnabled,
|
||||
searchCourseContentFromAPI,
|
||||
} from './api';
|
||||
|
||||
import {
|
||||
addModel,
|
||||
addModel, updateModel,
|
||||
} from '../../generic/model-store';
|
||||
|
||||
import {
|
||||
@@ -26,6 +28,8 @@ import {
|
||||
setCallToActionToast,
|
||||
} from './slice';
|
||||
|
||||
import mapSearchResponse from '../courseware-search/map-search-response';
|
||||
|
||||
const eventTypes = {
|
||||
POST_EVENT: 'post_event',
|
||||
};
|
||||
@@ -34,28 +38,41 @@ export function fetchTab(courseId, tab, getTabData, targetUserId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchTabRequest({ courseId }));
|
||||
try {
|
||||
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
dispatch(addModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
...courseHomeCourseMetadata,
|
||||
},
|
||||
}));
|
||||
const tabDataResult = getTabData && await getTabData(courseId, targetUserId);
|
||||
if (tabDataResult) {
|
||||
const promisesToFulfill = [getCourseHomeCourseMetadata(courseId, 'outline')];
|
||||
if (getTabData) {
|
||||
promisesToFulfill.push(getTabData(courseId, targetUserId));
|
||||
}
|
||||
const [
|
||||
courseHomeCourseMetadataResult,
|
||||
tabDataResult,
|
||||
] = await Promise.allSettled(promisesToFulfill);
|
||||
if (courseHomeCourseMetadataResult.status === 'fulfilled') {
|
||||
dispatch(addModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
...courseHomeCourseMetadataResult.value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (tabDataResult?.status === 'fulfilled') {
|
||||
dispatch(addModel({
|
||||
modelType: tab,
|
||||
model: {
|
||||
id: courseId,
|
||||
...tabDataResult,
|
||||
...tabDataResult.value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
// Disable the access-denied path for now - it caused a regression
|
||||
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
|
||||
if (courseHomeCourseMetadataResult.status === 'rejected') {
|
||||
throw courseHomeCourseMetadataResult.reason;
|
||||
} else if (!courseHomeCourseMetadataResult.value.courseAccess.hasAccess) {
|
||||
// If the learner does not have access to the course, short cut to dispatch to a denied response regardless of
|
||||
// the tabDataResult.
|
||||
dispatch(fetchTabDenied({ courseId }));
|
||||
} else if (tabDataResult || !getTabData) {
|
||||
} else if (tabDataResult?.status === 'rejected') {
|
||||
throw tabDataResult.reason;
|
||||
} else {
|
||||
dispatch(fetchTabSuccess({
|
||||
courseId,
|
||||
targetUserId,
|
||||
@@ -139,3 +156,70 @@ export function processEvent(eventData, getTabData) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCoursewareSearchSettings(courseId) {
|
||||
try {
|
||||
const { enabled } = await getCoursewareSearchEnabled(courseId);
|
||||
return { enabled };
|
||||
} catch (e) {
|
||||
return { enabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function searchCourseContent(courseId, searchKeyword) {
|
||||
return async (dispatch) => {
|
||||
const start = new Date();
|
||||
|
||||
dispatch(addModel({
|
||||
modelType: 'contentSearchResults',
|
||||
model: {
|
||||
id: courseId,
|
||||
searchKeyword,
|
||||
results: [],
|
||||
errors: undefined,
|
||||
loading: true,
|
||||
},
|
||||
}));
|
||||
|
||||
let data;
|
||||
let curatedResponse;
|
||||
let errors;
|
||||
try {
|
||||
({ data } = await searchCourseContentFromAPI(courseId, searchKeyword));
|
||||
curatedResponse = mapSearchResponse(data, searchKeyword);
|
||||
} catch (e) {
|
||||
// TODO: Remove when publishing to prod. Just temporary for performance debugging.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error on Courseware Search: ', e.message);
|
||||
errors = e.message;
|
||||
}
|
||||
|
||||
dispatch(updateModel({
|
||||
modelType: 'contentSearchResults',
|
||||
model: {
|
||||
...curatedResponse,
|
||||
id: courseId,
|
||||
searchKeyword,
|
||||
errors,
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
|
||||
const end = new Date();
|
||||
const clientMs = (end - start);
|
||||
const {
|
||||
took, total, maxScore, accessDeniedCount,
|
||||
} = data;
|
||||
|
||||
// TODO: Remove when publishing to prod. Just temporary for performance debugging.
|
||||
// eslint-disable-next-line no-console
|
||||
console.table({
|
||||
'Search Keyword': searchKeyword,
|
||||
'Client time (ms)': clientMs,
|
||||
'Server time (ms)': took,
|
||||
'Total matches': total,
|
||||
'Max score': maxScore,
|
||||
'Access denied count': accessDeniedCount,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import Timeline from './timeline/Timeline';
|
||||
@@ -14,7 +14,8 @@ import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
|
||||
import UpgradeToCompleteAlert from '../suggested-schedule-messaging/UpgradeToCompleteAlert';
|
||||
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
|
||||
|
||||
const DatesTab = ({ intl }) => {
|
||||
const DatesTab = () => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
@@ -59,8 +60,4 @@ const DatesTab = ({ intl }) => {
|
||||
);
|
||||
};
|
||||
|
||||
DatesTab.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(DatesTab);
|
||||
export default DatesTab;
|
||||
|
||||
@@ -135,6 +135,7 @@ describe('DatesTab', () => {
|
||||
});
|
||||
|
||||
it('shows extra info', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { items } = await getDay('Sat, Aug 17, 2030');
|
||||
expect(items).toHaveLength(3);
|
||||
|
||||
@@ -142,10 +143,12 @@ describe('DatesTab', () => {
|
||||
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);
|
||||
await waitForElementToBeRemoved(tooltip); // and it's gone again
|
||||
await user.hover(tipIcon);
|
||||
screen.getByText(tipText); // now it's there
|
||||
await user.unhover(tipIcon);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(tipText)).toBeNull(); // and it's gone again
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,9 @@ import { useSelector } from 'react-redux';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedTime,
|
||||
injectIntl,
|
||||
intlShape,
|
||||
useIntl,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Tooltip, OverlayTrigger } from '@edx/paragon';
|
||||
import { Tooltip, OverlayTrigger } from '@openedx/paragon';
|
||||
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
@@ -20,10 +19,10 @@ import { isLearnerAssignment } from '../utils';
|
||||
const Day = ({
|
||||
date,
|
||||
first,
|
||||
intl,
|
||||
items,
|
||||
last,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
@@ -108,7 +107,6 @@ const Day = ({
|
||||
Day.propTypes = {
|
||||
date: PropTypes.objectOf(Date).isRequired,
|
||||
first: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
date: PropTypes.string,
|
||||
dateType: PropTypes.string,
|
||||
@@ -126,4 +124,4 @@ Day.defaultProps = {
|
||||
last: false,
|
||||
};
|
||||
|
||||
export default injectIntl(Day);
|
||||
export default Day;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Badge } from '@edx/paragon';
|
||||
import { Badge } from '@openedx/paragon';
|
||||
|
||||
import messages from '../messages';
|
||||
import { daycmp, isLearnerAssignment } from '../utils';
|
||||
@@ -38,21 +38,21 @@ function getBadgeListAndColor(date, intl, item, items) {
|
||||
message: messages.today,
|
||||
shownForDay: isToday,
|
||||
bg: 'bg-warning-300',
|
||||
className: 'text-black',
|
||||
className: 'text-dark',
|
||||
},
|
||||
{
|
||||
message: messages.completed,
|
||||
shownForDay: assignments.length && assignments.every(isComplete),
|
||||
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
|
||||
bg: 'bg-light-500',
|
||||
className: 'text-black',
|
||||
className: 'text-dark',
|
||||
},
|
||||
{
|
||||
message: messages.pastDue,
|
||||
shownForDay: assignments.length && assignments.every(isPastDue),
|
||||
shownForItem: x => isLearnerAssignment(x) && isPastDue(x),
|
||||
bg: 'bg-dark-200',
|
||||
className: 'text-white',
|
||||
className: 'text-dark',
|
||||
},
|
||||
{
|
||||
message: messages.dueNext,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useParams, generatePath, useNavigate } from 'react-router-dom';
|
||||
@@ -30,6 +29,4 @@ const DiscussionTab = () => {
|
||||
);
|
||||
};
|
||||
|
||||
DiscussionTab.propTypes = {};
|
||||
|
||||
export default injectIntl(DiscussionTab);
|
||||
export default DiscussionTab;
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||
import HeaderSlot from '../../plugin-slots/HeaderSlot';
|
||||
import PageLoading from '../../generic/PageLoading';
|
||||
import { unsubscribeFromCourseGoal } from '../data/api';
|
||||
|
||||
import messages from './messages';
|
||||
import ResultPage from './ResultPage';
|
||||
|
||||
const GoalUnsubscribe = ({ intl }) => {
|
||||
const GoalUnsubscribe = () => {
|
||||
const intl = useIntl();
|
||||
const { token } = useParams();
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -38,7 +39,7 @@ const GoalUnsubscribe = ({ intl }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header showUserDropdown={false} />
|
||||
<HeaderSlot showUserDropdown={false} />
|
||||
<main id="main-content" className="container my-5 text-center">
|
||||
{isLoading && (
|
||||
<PageLoading srMessage={`${intl.formatMessage(messages.loading)}`} />
|
||||
@@ -51,8 +52,4 @@ const GoalUnsubscribe = ({ intl }) => {
|
||||
);
|
||||
};
|
||||
|
||||
GoalUnsubscribe.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(GoalUnsubscribe);
|
||||
export default GoalUnsubscribe;
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Hyperlink } from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Hyperlink } from '@openedx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
import { ReactComponent as UnsubscribeIcon } from './unsubscribe.svg';
|
||||
|
||||
const ResultPage = ({ courseTitle, error, intl }) => {
|
||||
const errorDescription = (
|
||||
<FormattedMessage
|
||||
id="learning.goals.unsubscribe.errorDescription"
|
||||
defaultMessage="We were unable to unsubscribe you from goal reminder emails. Please try again later or {contactSupport} for help."
|
||||
values={{
|
||||
contactSupport: (
|
||||
<Hyperlink
|
||||
className="text-reset"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().CONTACT_URL}`}
|
||||
>
|
||||
{intl.formatMessage(messages.contactSupport)}
|
||||
</Hyperlink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
const ResultPage = ({ courseTitle, error }) => {
|
||||
const intl = useIntl();
|
||||
const errorDescription = intl.formatMessage(
|
||||
messages.errorDescription,
|
||||
{
|
||||
contactSupport: (
|
||||
<Hyperlink
|
||||
className="text-reset"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().CONTACT_URL}`}
|
||||
>
|
||||
{intl.formatMessage(messages.contactSupport)}
|
||||
</Hyperlink>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const header = error
|
||||
@@ -54,7 +52,6 @@ ResultPage.defaultProps = {
|
||||
ResultPage.propTypes = {
|
||||
courseTitle: PropTypes.string,
|
||||
error: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ResultPage);
|
||||
export default ResultPage;
|
||||
|
||||
@@ -16,6 +16,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Something went wrong',
|
||||
description: 'It indicate that the unsubscribing request has failed',
|
||||
},
|
||||
errorDescription: {
|
||||
id: 'learning.goals.unsubscribe.errorDescription',
|
||||
defaultMessage: 'We were unable to unsubscribe you from goal reminder emails. Please try again later or {contactSupport} for help.',
|
||||
description: 'Message that notifies user that unsubscribing failed and to try again',
|
||||
},
|
||||
goToDashboard: {
|
||||
id: 'learning.goals.unsubscribe.goToDashboard',
|
||||
defaultMessage: 'Go to dashboard',
|
||||
@@ -65,6 +65,7 @@ const DateSummary = ({
|
||||
)}
|
||||
{!linkedTitle && dateBlock.link && (
|
||||
<a
|
||||
id={dateBlock.dateType === 'verified-upgrade-deadline' ? 'date-verified-upgrade-deadline' : ''}
|
||||
href={dateBlock.link}
|
||||
onClick={dateBlock.dateType === 'verified-upgrade-deadline' ? logVerifiedUpgradeClick : () => {}}
|
||||
className="description-link"
|
||||
|
||||
@@ -9,8 +9,9 @@ const LmsHtmlFragment = ({
|
||||
title,
|
||||
...rest
|
||||
}) => {
|
||||
const direction = document.documentElement?.getAttribute('dir') || 'ltr';
|
||||
const wholePage = `
|
||||
<html>
|
||||
<html dir="${direction}">
|
||||
<head>
|
||||
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
|
||||
<link rel="stylesheet" href="/static/${getConfig().LEGACY_THEME_NAME ? `${getConfig().LEGACY_THEME_NAME}/` : ''}css/bootstrap/lms-main.css">
|
||||
@@ -29,7 +30,7 @@ const LmsHtmlFragment = ({
|
||||
const iframe = useRef(null);
|
||||
function resetIframeHeight() {
|
||||
if (iframe?.current?.contentWindow?.document?.body) {
|
||||
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
|
||||
iframe.current.height = iframe.current.contentWindow.document.body.parentNode.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { CourseOutlineTabNotificationsSlot } from '../../plugin-slots/CourseOutlineTabNotificationsSlot';
|
||||
import { AlertList } from '../../generic/user-messages';
|
||||
|
||||
import CourseDates from './widgets/CourseDates';
|
||||
@@ -14,9 +15,7 @@ import WeeklyLearningGoalCard from './widgets/WeeklyLearningGoalCard';
|
||||
import CourseTools from './widgets/CourseTools';
|
||||
import { fetchOutlineTab } from '../data';
|
||||
import messages from './messages';
|
||||
import Section from './Section';
|
||||
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
|
||||
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
|
||||
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
|
||||
import useCertificateAvailableAlert from './alerts/certificate-status-alert';
|
||||
import useCourseEndAlert from './alerts/course-end-alert';
|
||||
@@ -27,8 +26,10 @@ import { useModel } from '../../generic/model-store';
|
||||
import WelcomeMessage from './widgets/WelcomeMessage';
|
||||
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
|
||||
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
|
||||
import CourseHomeSectionOutlineSlot from '../../plugin-slots/CourseHomeSectionOutlineSlot';
|
||||
|
||||
const OutlineTab = ({ intl }) => {
|
||||
const OutlineTab = () => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
courseId,
|
||||
proctoringPanelStatus,
|
||||
@@ -38,11 +39,11 @@ const OutlineTab = ({ intl }) => {
|
||||
isSelfPaced,
|
||||
org,
|
||||
title,
|
||||
userTimezone,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const expandButtonRef = useRef();
|
||||
|
||||
const {
|
||||
accessExpiration,
|
||||
courseBlocks: {
|
||||
courses,
|
||||
sections,
|
||||
@@ -51,20 +52,12 @@ const OutlineTab = ({ intl }) => {
|
||||
selectedGoal,
|
||||
weeklyLearningGoalEnabled,
|
||||
} = {},
|
||||
datesBannerInfo,
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
},
|
||||
enableProctoredExams,
|
||||
offer,
|
||||
timeOffsetMillis,
|
||||
verifiedMode,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const {
|
||||
marketingUrl,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -158,27 +151,21 @@ const OutlineTab = ({ intl }) => {
|
||||
</>
|
||||
)}
|
||||
<StartOrResumeCourseCard />
|
||||
<WelcomeMessage courseId={courseId} />
|
||||
<WelcomeMessage courseId={courseId} nextElementRef={expandButtonRef} />
|
||||
{rootCourseId && (
|
||||
<>
|
||||
<div className="row w-100 m-0 mb-3 justify-content-end">
|
||||
<div id="expand-button-row" className="row w-100 m-0 mb-3 justify-content-end">
|
||||
<div className="col-12 col-md-auto p-0">
|
||||
<Button variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
|
||||
<Button ref={expandButtonRef} variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
|
||||
{expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ol id="courseHome-outline" className="list-unstyled">
|
||||
{courses[rootCourseId].sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={sectionId}
|
||||
courseId={courseId}
|
||||
defaultOpen={sections[sectionId].resumeBlock}
|
||||
expand={expandAll}
|
||||
section={sections[sectionId]}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
<CourseHomeSectionOutlineSlot
|
||||
expandAll={expandAll}
|
||||
sectionIds={courses[rootCourseId].sectionIds}
|
||||
sections={sections}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -194,19 +181,7 @@ const OutlineTab = ({ intl }) => {
|
||||
/>
|
||||
)}
|
||||
<CourseTools />
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="course_home"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
/>
|
||||
<CourseOutlineTabNotificationsSlot courseId={courseId} />
|
||||
<CourseDates />
|
||||
<CourseHandouts />
|
||||
</div>
|
||||
@@ -216,8 +191,4 @@ const OutlineTab = ({ intl }) => {
|
||||
);
|
||||
};
|
||||
|
||||
OutlineTab.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(OutlineTab);
|
||||
export default OutlineTab;
|
||||
|
||||
@@ -5,7 +5,7 @@ import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import Cookies from 'js-cookie';
|
||||
@@ -23,8 +23,26 @@ import { CERT_STATUS_TYPE } from './alerts/certificate-status-alert/CertificateS
|
||||
import OutlineTab from './OutlineTab';
|
||||
import LoadedTabPage from '../../tab-page/LoadedTabPage';
|
||||
|
||||
const mockCoursewareSearchParams = jest.fn();
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
jest.mock('../courseware-search/hooks', () => ({
|
||||
...jest.requireActual('../courseware-search/hooks'),
|
||||
useCoursewareSearchParams: () => mockCoursewareSearchParams,
|
||||
}));
|
||||
|
||||
const coursewareSearch = {
|
||||
query: '',
|
||||
filter: '',
|
||||
setQuery: jest.fn(),
|
||||
setFilter: jest.fn(),
|
||||
clearSearchParams: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSearchParams = ((props = coursewareSearch) => {
|
||||
mockCoursewareSearchParams.mockReturnValue(props);
|
||||
});
|
||||
|
||||
describe('Outline Tab', () => {
|
||||
let axiosMock;
|
||||
@@ -36,7 +54,7 @@ describe('Outline Tab', () => {
|
||||
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
|
||||
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
|
||||
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
||||
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}&username=MockUser`;
|
||||
const proctoringInfoUrl = `${getConfig().EXAMS_BASE_URL}/api/v1/student/course_id/${encodeURIComponent(courseId)}/onboarding?username=MockUser`;
|
||||
|
||||
const store = initializeStore();
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata');
|
||||
@@ -77,9 +95,16 @@ describe('Outline Tab', () => {
|
||||
expiration_date: null,
|
||||
});
|
||||
|
||||
// Mock courseware search params
|
||||
mockSearchParams();
|
||||
|
||||
logUnhandledRequests(axiosMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Course Outline', () => {
|
||||
it('displays link to start course', async () => {
|
||||
await fetchAndRender();
|
||||
@@ -107,7 +132,18 @@ describe('Outline Tab', () => {
|
||||
expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('includes outline_tab_notifications_slot', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
expect(screen.getByTestId('org.openedx.frontend.learning.course_outline_tab_notifications.v1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles expand/collapse all button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
await fetchAndRender();
|
||||
// Button renders as "Expand All"
|
||||
const expandButton = screen.getByRole('button', { name: 'Expand all' });
|
||||
@@ -118,11 +154,11 @@ describe('Outline Tab', () => {
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
// Click to expand section
|
||||
userEvent.click(expandButton);
|
||||
await user.click(expandButton);
|
||||
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true'));
|
||||
|
||||
// Click to collapse section
|
||||
userEvent.click(expandButton);
|
||||
await user.click(expandButton);
|
||||
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false'));
|
||||
});
|
||||
|
||||
@@ -132,7 +168,7 @@ describe('Outline Tab', () => {
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTitle('Completed section')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Completed section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays correct icon for incomplete assignment', async () => {
|
||||
@@ -141,7 +177,7 @@ describe('Outline Tab', () => {
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Incomplete section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('SequenceLink displays link', async () => {
|
||||
@@ -240,21 +276,50 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
|
||||
it('renders show more/less button and handles click', async () => {
|
||||
const user = userEvent.setup();
|
||||
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
|
||||
let showMoreButton = screen.getByRole('button', { name: 'Show More' });
|
||||
expect(showMoreButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(showMoreButton);
|
||||
await user.click(showMoreButton);
|
||||
let showLessButton = screen.getByRole('button', { name: 'Show Less' });
|
||||
expect(showLessButton).toBeInTheDocument();
|
||||
expect(screen.getByTestId('long-welcome-message-iframe')).toBeInTheDocument();
|
||||
|
||||
userEvent.click(showLessButton);
|
||||
await user.click(showLessButton);
|
||||
showLessButton = screen.queryByRole('button', { name: 'Show Less' });
|
||||
expect(showLessButton).not.toBeInTheDocument();
|
||||
showMoreButton = screen.getByRole('button', { name: 'Show More' });
|
||||
expect(showMoreButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dismisses message', async () => {
|
||||
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
|
||||
const dismissButton = screen.queryByRole('button', { name: 'Dismiss' });
|
||||
const expandButton = screen.queryByRole('button', { name: 'Expand all' });
|
||||
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
expect(expandButton).toHaveFocus();
|
||||
|
||||
expect(screen.queryByText('Welcome Message')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores comments and misformatted HTML', async () => {
|
||||
setTabData({
|
||||
welcome_message_html: '<p class="additional-spaces-in-tag" >'
|
||||
+ '<!-- Even if the welcome_message_html length is above the limit because of comments, we hope it will not be shortened. -->'
|
||||
+ '<!-- Even if the welcome_message_html length is above the limit because of comments, we hope it will not be shortened. -->'
|
||||
+ 'Test welcome message that happens to be longer than one hundred words because of comments but displayed content is less.'
|
||||
+ 'It should not be shortened.'
|
||||
+ '<!-- Even if the welcome_message_html length is above the limit because of comments, we hope it will not be shortened. -->'
|
||||
+ '<!-- Even if the welcome_message_html length is above the limit because of comments, we hope it will not be shortened. -->'
|
||||
+ '</p>',
|
||||
});
|
||||
await fetchAndRender();
|
||||
const showMoreButton = screen.queryByRole('button', { name: 'Show More' });
|
||||
expect(showMoreButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display if no update available', async () => {
|
||||
@@ -1125,80 +1190,6 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Upgrade Card', () => {
|
||||
it('renders title when upgrade is available', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays link to upgrade', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('viewing upgrade card sends analytics', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
sendTrackingLogEvent.mockClear();
|
||||
await fetchAndRender();
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('Promotion Viewed', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
creative: 'sidebarupsell',
|
||||
name: 'In-Course Verification Prompt',
|
||||
position: 'sidebar-message',
|
||||
promotion_id: 'courseware_verified_certificate_upsell',
|
||||
});
|
||||
|
||||
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.bi.course.upgrade.sidebarupsell.displayed', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking upgrade link sends analytics', async () => {
|
||||
await fetchAndRender();
|
||||
|
||||
// Clearing after render to remove any events sent on view (ex. 'Promotion Viewed')
|
||||
sendTrackEvent.mockClear();
|
||||
sendTrackingLogEvent.mockClear();
|
||||
const upgradeButton = screen.getByRole('link', { name: 'Upgrade for $149' });
|
||||
|
||||
fireEvent.click(upgradeButton);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'Promotion Clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
creative: 'sidebarupsell',
|
||||
name: 'In-Course Verification Prompt',
|
||||
position: 'sidebar-message',
|
||||
promotion_id: 'courseware_verified_certificate_upsell',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'green_upgrade',
|
||||
linkName: 'course_home_green',
|
||||
linkType: 'button',
|
||||
pageName: 'course_home',
|
||||
});
|
||||
|
||||
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackingLogEvent).toHaveBeenNthCalledWith(1, 'edx.bi.course.upgrade.sidebarupsell.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
});
|
||||
expect(sendTrackingLogEvent).toHaveBeenNthCalledWith(2, 'edx.course.enrollment.upgrade.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
location: 'sidebar-message',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Account Activation Alert', () => {
|
||||
beforeEach(() => {
|
||||
const intersectionObserverMock = () => ({
|
||||
@@ -1244,5 +1235,97 @@ describe('Outline Tab', () => {
|
||||
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
|
||||
expect(axiosMock.history.post[0].url).toEqual(resendEmailUrl);
|
||||
});
|
||||
|
||||
it('section should show hidden from toc message when hide_from_toc is true', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
const courseBlocksIds = Object.keys(courseBlocks.blocks);
|
||||
const newCourseBlocks = courseBlocksIds.reduce((blocks, blockId) => ({
|
||||
...blocks,
|
||||
[blockId]: {
|
||||
...courseBlocks.blocks[blockId],
|
||||
hide_from_toc: true,
|
||||
},
|
||||
}), {});
|
||||
|
||||
setTabData({
|
||||
course_blocks: { blocks: newCourseBlocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
const iconHiddenFromTocSectionNode = screen.getByTestId('hide-from-toc-section-icon');
|
||||
const textHiddenFromTocSectionNode = screen.getByTestId('hide-from-toc-section-text');
|
||||
expect(iconHiddenFromTocSectionNode).toBeInTheDocument();
|
||||
expect(textHiddenFromTocSectionNode).toBeInTheDocument();
|
||||
expect(textHiddenFromTocSectionNode.textContent).toBe('Hidden in Course Outline, accessible via link');
|
||||
});
|
||||
|
||||
it('section should not show hidden from toc message when hide_from_toc is false', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
const courseBlocksIds = Object.keys(courseBlocks.blocks);
|
||||
const newCourseBlocks = courseBlocksIds.reduce((blocks, blockId) => ({
|
||||
...blocks,
|
||||
[blockId]: {
|
||||
...courseBlocks.blocks[blockId],
|
||||
hide_from_toc: false,
|
||||
},
|
||||
}), {});
|
||||
|
||||
setTabData({
|
||||
course_blocks: { blocks: newCourseBlocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
const iconHiddenFromTocSectionNode = screen.queryByTestId('hide-from-toc-section-icon');
|
||||
const textHiddenFromTocSectionNode = screen.queryByTestId('hide-from-toc-section-text');
|
||||
|
||||
expect(iconHiddenFromTocSectionNode).not.toBeInTheDocument();
|
||||
expect(textHiddenFromTocSectionNode).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sequence link should show hidden from toc message when hide_from_toc is true', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
const courseBlocksIds = Object.keys(courseBlocks.blocks);
|
||||
const newCourseBlocks = courseBlocksIds.reduce((blocks, blockId) => ({
|
||||
...blocks,
|
||||
[blockId]: {
|
||||
...courseBlocks.blocks[blockId],
|
||||
hide_from_toc: true,
|
||||
},
|
||||
}), {});
|
||||
|
||||
setTabData({
|
||||
course_blocks: { blocks: newCourseBlocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
const iconHiddenFromTocSequenceLinkNode = screen.getByTestId('hide-from-toc-sequence-link-icon');
|
||||
const textHiddenFromTocSequenceLink = screen.getByTestId('hide-from-toc-sequence-link-text');
|
||||
expect(iconHiddenFromTocSequenceLinkNode).toBeInTheDocument();
|
||||
expect(textHiddenFromTocSequenceLink).toBeInTheDocument();
|
||||
expect(textHiddenFromTocSequenceLink.textContent).toBe('Subsections are not navigable between each other, they can only be accessed through their link.');
|
||||
});
|
||||
|
||||
it('sequence link not show hidden from toc message when hide_from_toc is false', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
const courseBlocksIds = Object.keys(courseBlocks.blocks);
|
||||
const newCourseBlocks = courseBlocksIds.reduce((blocks, blockId) => ({
|
||||
...blocks,
|
||||
[blockId]: {
|
||||
...courseBlocks.blocks[blockId],
|
||||
hide_from_toc: false,
|
||||
},
|
||||
}), {});
|
||||
|
||||
setTabData({
|
||||
course_blocks: { blocks: newCourseBlocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
const iconHiddenFromTocSequenceLink = screen.queryByTestId('hide-from-toc-sequence-link-icon');
|
||||
const textHiddenFromTocSequenceLink = screen.queryByTestId('hide-from-toc-sequence-link-text');
|
||||
|
||||
expect(iconHiddenFromTocSequenceLink).not.toBeInTheDocument();
|
||||
expect(textHiddenFromTocSequenceLink).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
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';
|
||||
|
||||
const Section = ({
|
||||
courseId,
|
||||
defaultOpen,
|
||||
expand,
|
||||
intl,
|
||||
section,
|
||||
}) => {
|
||||
const {
|
||||
complete,
|
||||
sequenceIds,
|
||||
title,
|
||||
} = section;
|
||||
const {
|
||||
courseBlocks: {
|
||||
sequences,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(expand);
|
||||
}, [expand]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(defaultOpen);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const sectionTitle = (
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-auto p-0">
|
||||
{complete ? (
|
||||
<FontAwesomeIcon
|
||||
icon={fasCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left mt-1 text-success"
|
||||
aria-hidden="true"
|
||||
title={intl.formatMessage(messages.completedSection)}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={farCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left mt-1 text-gray-400"
|
||||
aria-hidden="true"
|
||||
title={intl.formatMessage(messages.incompleteSection)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-10 ml-3 p-0 font-weight-bold text-dark-500">
|
||||
<span className="align-middle">{title}</span>
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<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); }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
iconWhenOpen={(
|
||||
<IconButton
|
||||
alt={intl.formatMessage(genericMessages.close)}
|
||||
icon={faMinus}
|
||||
onClick={() => { setOpen(false); }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<ol className="list-unstyled">
|
||||
{sequenceIds.map((sequenceId, index) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequenceId}
|
||||
courseId={courseId}
|
||||
sequence={sequences[sequenceId]}
|
||||
first={index === 0}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</Collapsible>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
Section.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
defaultOpen: PropTypes.bool.isRequired,
|
||||
expand: PropTypes.bool.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
section: PropTypes.shape().isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Section);
|
||||
@@ -1,135 +0,0 @@
|
||||
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 EffortEstimate from '../../shared/effort-estimate';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
const SequenceLink = ({
|
||||
id,
|
||||
intl,
|
||||
courseId,
|
||||
first,
|
||||
sequence,
|
||||
}) => {
|
||||
const {
|
||||
complete,
|
||||
description,
|
||||
due,
|
||||
showLink,
|
||||
title,
|
||||
} = sequence;
|
||||
const {
|
||||
userTimezone,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
|
||||
const displayTitle = showLink ? coursewareUrl : title;
|
||||
|
||||
const dueDateMessage = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due-date-set"
|
||||
defaultMessage="{description} due {assignmentDue}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const noDueDateMessage = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due-date-not-set"
|
||||
defaultMessage="{description}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<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-400 mt-1"
|
||||
aria-hidden="true"
|
||||
title={intl.formatMessage(messages.incompleteAssignment)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-10 p-0 ml-3 text-break">
|
||||
<span className="align-middle">{displayTitle}</span>
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)}
|
||||
</span>
|
||||
<EffortEstimate className="ml-3 align-middle" block={sequence} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
{due ? dueDateMessage : noDueDateMessage}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
SequenceLink.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
first: PropTypes.bool.isRequired,
|
||||
sequence: PropTypes.shape().isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SequenceLink);
|
||||
@@ -3,10 +3,9 @@ import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedMessage,
|
||||
injectIntl,
|
||||
intlShape,
|
||||
useIntl,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Button } from '@edx/paragon';
|
||||
import { Alert, Button } from '@openedx/paragon';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
@@ -25,7 +24,8 @@ export const CERT_STATUS_TYPE = {
|
||||
UNVERIFIED: 'unverified',
|
||||
};
|
||||
|
||||
const CertificateStatusAlert = ({ intl, payload }) => {
|
||||
const CertificateStatusAlert = ({ payload }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
certificateAvailableDate,
|
||||
@@ -192,7 +192,6 @@ const CertificateStatusAlert = ({ intl, payload }) => {
|
||||
};
|
||||
|
||||
CertificateStatusAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
certificateAvailableDate: PropTypes.string,
|
||||
certStatus: PropTypes.string,
|
||||
@@ -210,4 +209,4 @@ CertificateStatusAlert.propTypes = {
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CertificateStatusAlert);
|
||||
export default CertificateStatusAlert;
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
FormattedRelativeTime,
|
||||
FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
import { Alert } from '@openedx/paragon';
|
||||
import { Info } from '@openedx/paragon/icons';
|
||||
|
||||
const DAY_SEC = 24 * 60 * 60; // in seconds
|
||||
const DAY_MS = DAY_SEC * 1000; // in ms
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
|
||||
import { Alert, Button, Hyperlink } from '@edx/paragon';
|
||||
import { Alert, Button, Hyperlink } from '@openedx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
@@ -14,7 +14,8 @@ import outlineMessages from '../../messages';
|
||||
import useEnrollClickHandler from '../../../../alerts/enrollment-alert/clickHook';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
const PrivateCourseAlert = ({ intl, payload }) => {
|
||||
const PrivateCourseAlert = ({ payload }) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
anonymousUser,
|
||||
canEnroll,
|
||||
@@ -103,7 +104,6 @@ const PrivateCourseAlert = ({ intl, payload }) => {
|
||||
};
|
||||
|
||||
PrivateCourseAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
anonymousUser: PropTypes.bool,
|
||||
canEnroll: PropTypes.bool,
|
||||
@@ -111,4 +111,4 @@ PrivateCourseAlert.propTypes = {
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(PrivateCourseAlert);
|
||||
export default PrivateCourseAlert;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Button } from '@edx/paragon';
|
||||
import { Alert, Button } from '@openedx/paragon';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
|
||||
@@ -36,6 +36,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Completed section',
|
||||
description: 'Text used to describe the green checkmark icon in front of a section title',
|
||||
},
|
||||
hiddenSection: {
|
||||
id: 'learning.outline.hiddenSection',
|
||||
defaultMessage: 'Hidden in Course Outline, accessible via link',
|
||||
description: 'Label for hidden section in course outline',
|
||||
},
|
||||
hiddenSequenceLink: {
|
||||
id: 'learning.outline.hiddenSequenceLink',
|
||||
defaultMessage: 'Subsections are not navigable between each other, they can only be accessed through their link.',
|
||||
description: 'Label for hidden sequence in course outline',
|
||||
},
|
||||
dates: {
|
||||
id: 'learning.outline.dates',
|
||||
defaultMessage: 'Important dates',
|
||||
@@ -331,6 +341,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Onboarding Past Due',
|
||||
description: 'Text that show when the deadline of proctortrack onboarding exam has passed, it appears on button that start the onboarding exam however for this case the button is disabled for obvious reason',
|
||||
},
|
||||
sequenceDueDate: {
|
||||
id: 'learning.outline.sequence-due-date-set',
|
||||
defaultMessage: '{description} due {assignmentDue}',
|
||||
description: 'Used below an assignment title',
|
||||
},
|
||||
sequenceNoDueDate: {
|
||||
id: 'learning.outline.sequence-due-date-not-set',
|
||||
defaultMessage: '{description}',
|
||||
description: 'Used below an assignment title',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { Block } from '@openedx/paragon/icons';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
interface Props {}
|
||||
|
||||
const HiddenSequenceLink: React.FC<Props> = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="row w-100 my-2 mx-4 pl-3">
|
||||
<span className="small d-flex">
|
||||
<Icon className="mr-2" src={Block} data-testid="hide-from-toc-sequence-link-icon" />
|
||||
<span data-testid="hide-from-toc-sequence-link-text">
|
||||
{intl.formatMessage(messages.hiddenSequenceLink)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HiddenSequenceLink;
|
||||
94
src/course-home/outline-tab/section-outline/Section.tsx
Normal file
94
src/course-home/outline-tab/section-outline/Section.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Collapsible, IconButton } from '@openedx/paragon';
|
||||
import { Minus, Plus } from '@openedx/paragon/icons';
|
||||
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import genericMessages from '../../../generic/messages';
|
||||
import { useContextId } from '../../../data/hooks';
|
||||
import messages from '../messages';
|
||||
import SectionTitle from './SectionTitle';
|
||||
import SequenceLink from './SequenceLink';
|
||||
|
||||
interface Props {
|
||||
defaultOpen: boolean;
|
||||
expand: boolean;
|
||||
section: {
|
||||
complete: boolean;
|
||||
sequenceIds: string[];
|
||||
title: string;
|
||||
hideFromTOC: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const Section: React.FC<Props> = ({
|
||||
defaultOpen,
|
||||
expand,
|
||||
section,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const courseId = useContextId();
|
||||
const {
|
||||
complete,
|
||||
sequenceIds,
|
||||
title,
|
||||
hideFromTOC,
|
||||
} = section;
|
||||
const {
|
||||
courseBlocks: {
|
||||
sequences,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(expand);
|
||||
}, [expand]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(defaultOpen);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Collapsible
|
||||
className="mb-2"
|
||||
styling="card-lg"
|
||||
title={<SectionTitle {...{ complete, hideFromTOC, title }} />}
|
||||
open={open}
|
||||
onToggle={() => { setOpen(!open); }}
|
||||
iconWhenClosed={(
|
||||
<IconButton
|
||||
alt={intl.formatMessage(messages.openSection)}
|
||||
iconAs={Plus}
|
||||
onClick={() => { setOpen(true); }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
iconWhenOpen={(
|
||||
<IconButton
|
||||
alt={intl.formatMessage(genericMessages.close)}
|
||||
iconAs={Minus}
|
||||
onClick={() => { setOpen(false); }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<ol className="list-unstyled">
|
||||
{sequenceIds.map((sequenceId, index) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequenceId}
|
||||
sequence={sequences[sequenceId]}
|
||||
first={index === 0}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</Collapsible>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
59
src/course-home/outline-tab/section-outline/SectionTitle.tsx
Normal file
59
src/course-home/outline-tab/section-outline/SectionTitle.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { CheckCircle, CheckCircleOutline, DisabledVisible } from '@openedx/paragon/icons';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
interface Props {
|
||||
complete: boolean;
|
||||
hideFromTOC: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const SectionTitle: React.FC<Props> = ({ complete, hideFromTOC, title }) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<div className="d-flex row w-100 m-0">
|
||||
<div className="col-auto p-0">
|
||||
{complete ? (
|
||||
<Icon
|
||||
src={CheckCircle}
|
||||
className="float-left mt-1 text-success"
|
||||
aria-hidden="true"
|
||||
svgAttrs={{ 'aria-label': intl.formatMessage(messages.completedSection) }}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
src={CheckCircleOutline}
|
||||
className="float-left mt-1 text-gray-400"
|
||||
aria-hidden="true"
|
||||
svgAttrs={{ 'aria-label': intl.formatMessage(messages.incompleteSection) }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-7 ml-3 p-0 font-weight-bold text-dark-500">
|
||||
<span className="align-middle col-6">{title}</span>
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
|
||||
</span>
|
||||
</div>
|
||||
{hideFromTOC && (
|
||||
<div className="row">
|
||||
{hideFromTOC && (
|
||||
<span className="small d-flex align-content-end">
|
||||
<Icon className="mr-2" src={DisabledVisible} data-testid="hide-from-toc-section-icon" />
|
||||
<span data-testid="hide-from-toc-section-text">
|
||||
{intl.formatMessage(messages.hiddenSection)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionTitle;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { FormattedTime, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import { useContextId } from '../../../data/hooks';
|
||||
import messages from '../messages';
|
||||
|
||||
interface Props {
|
||||
due: string;
|
||||
id: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SequenceDueDate: React.FC<Props> = ({
|
||||
due,
|
||||
id,
|
||||
description,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const courseId = useContextId();
|
||||
let dueDateMessage: string | React.ReactNode = intl.formatMessage(
|
||||
messages.sequenceNoDueDate,
|
||||
{ description: description || '' },
|
||||
);
|
||||
const {
|
||||
userTimezone,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
if (due) {
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
dueDateMessage = intl.formatMessage(
|
||||
messages.sequenceDueDate,
|
||||
{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
{dueDateMessage}
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SequenceDueDate;
|
||||
56
src/course-home/outline-tab/section-outline/SequenceLink.tsx
Normal file
56
src/course-home/outline-tab/section-outline/SequenceLink.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import SequenceDueDate from './SequenceDueDate';
|
||||
import HiddenSequenceLink from './HiddenSequenceLink';
|
||||
import SequenceTitle from './SequenceTitle';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
first: boolean;
|
||||
sequence: {
|
||||
complete: boolean;
|
||||
description: string;
|
||||
due: string;
|
||||
showLink: boolean;
|
||||
title: string;
|
||||
hideFromTOC: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const SequenceLink: React.FC<Props> = ({
|
||||
id,
|
||||
first,
|
||||
sequence,
|
||||
}) => {
|
||||
const {
|
||||
complete,
|
||||
description,
|
||||
due,
|
||||
showLink,
|
||||
title,
|
||||
hideFromTOC,
|
||||
} = sequence;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
|
||||
<SequenceTitle
|
||||
{...{
|
||||
complete,
|
||||
showLink,
|
||||
title,
|
||||
sequence,
|
||||
id,
|
||||
}}
|
||||
/>
|
||||
{hideFromTOC && (
|
||||
<HiddenSequenceLink />
|
||||
)}
|
||||
<SequenceDueDate {...{ due, id, description }} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default SequenceLink;
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { CheckCircleOutline, CheckCircle } from '@openedx/paragon/icons';
|
||||
|
||||
import EffortEstimate from '../../../shared/effort-estimate';
|
||||
import messages from '../messages';
|
||||
import { useContextId } from '../../../data/hooks';
|
||||
|
||||
interface Props {
|
||||
complete: boolean;
|
||||
showLink: boolean;
|
||||
title: string;
|
||||
sequence: object;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const SequenceTitle: React.FC<Props> = ({
|
||||
complete,
|
||||
showLink,
|
||||
title,
|
||||
sequence,
|
||||
id,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const courseId = useContextId();
|
||||
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
|
||||
const displayTitle = showLink ? coursewareUrl : title;
|
||||
|
||||
return (
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-auto p-0">
|
||||
{complete ? (
|
||||
<Icon
|
||||
src={CheckCircle}
|
||||
className="float-left text-success mt-1"
|
||||
aria-hidden={complete}
|
||||
svgAttrs={{ 'aria-label': intl.formatMessage(messages.completedAssignment) }}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
src={CheckCircleOutline}
|
||||
className="float-left text-gray-400 mt-1"
|
||||
aria-hidden={complete}
|
||||
svgAttrs={{ 'aria-label': intl.formatMessage(messages.incompleteAssignment) }}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-10 p-0 ml-3 text-break">
|
||||
<span className="align-middle">{displayTitle}</span>
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)}
|
||||
</span>
|
||||
<EffortEstimate className="ml-3 align-middle" block={sequence} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SequenceTitle;
|
||||
@@ -1,15 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import DateSummary from '../DateSummary';
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
const CourseDates = ({
|
||||
intl,
|
||||
}) => {
|
||||
const CourseDates = () => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
@@ -40,7 +39,7 @@ const CourseDates = ({
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
<a className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
|
||||
<a id="dates-tab-link" className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
|
||||
{intl.formatMessage(messages.allDates)}
|
||||
</a>
|
||||
</div>
|
||||
@@ -48,8 +47,4 @@ const CourseDates = ({
|
||||
);
|
||||
};
|
||||
|
||||
CourseDates.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseDates);
|
||||
export default CourseDates;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import LmsHtmlFragment from '../LmsHtmlFragment';
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
const CourseHandouts = ({ intl }) => {
|
||||
const CourseHandouts = () => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
@@ -31,8 +32,4 @@ const CourseHandouts = ({ intl }) => {
|
||||
);
|
||||
};
|
||||
|
||||
CourseHandouts.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseHandouts);
|
||||
export default CourseHandouts;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user