Compare commits
428 Commits
open-relea
...
abdullahwa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb5f51e47 | ||
|
|
6a115797e6 | ||
|
|
f57d40ea34 | ||
|
|
80bf86992d | ||
|
|
1dde30a0a2 | ||
|
|
9a6e12bd3b | ||
|
|
784a811ff8 | ||
|
|
071aee5b02 | ||
|
|
da68fb8e9d | ||
|
|
972a7f324c | ||
|
|
c809dfb2e4 | ||
|
|
1b2c65fae6 | ||
|
|
b09729b55e | ||
|
|
60917c6ab5 | ||
|
|
d57ecc6779 | ||
|
|
6ae9cdac00 | ||
|
|
9740974bbd | ||
|
|
a88a88e9af | ||
|
|
6baec5b6a3 | ||
|
|
8acd27d7bf | ||
|
|
d76aaa73a4 | ||
|
|
4e70813fa9 | ||
|
|
7f5687f175 | ||
|
|
c8434b87c0 | ||
|
|
9299f4cf93 | ||
|
|
59d2dcaacb | ||
|
|
42f8c3d95f | ||
|
|
9ff77945e3 | ||
|
|
584823b879 | ||
|
|
6e83e90cf0 | ||
|
|
ad7ba2f302 | ||
|
|
bec59e5bbe | ||
|
|
1fdddfb869 | ||
|
|
b5a287639d | ||
|
|
dad4bd5282 | ||
|
|
17b1360c07 | ||
|
|
c39b52a6bf | ||
|
|
642b4e4052 | ||
|
|
423a3f3f72 | ||
|
|
f1f036576e | ||
|
|
deb76a0609 | ||
|
|
912c42e802 | ||
|
|
bdd641225f | ||
|
|
9021fccdb7 | ||
|
|
e4d88fb1fa | ||
|
|
073e191273 | ||
|
|
f8095e6670 | ||
|
|
e4c3997d17 | ||
|
|
da7fe95f24 | ||
|
|
896969c7de | ||
|
|
8100281fb4 | ||
|
|
f035391c2f | ||
|
|
3607e6423d | ||
|
|
4395607074 | ||
|
|
f717cdac86 | ||
|
|
40c9d6ee0d | ||
|
|
3c661e15cb | ||
|
|
49fce4622c | ||
|
|
608b2f79f8 | ||
|
|
6b57ce3e53 | ||
|
|
6aff1c1168 | ||
|
|
2b11df9eb5 | ||
|
|
7fcc501d2e | ||
|
|
90fb3d8edc | ||
|
|
0fc0ce0829 | ||
|
|
16d2f38325 | ||
|
|
76bb8e88c1 | ||
|
|
51c5f9c4dc | ||
|
|
60c1a0343c | ||
|
|
1555e9f88e | ||
|
|
3938015aaa | ||
|
|
a318c322b2 | ||
|
|
b234344aab | ||
|
|
4850302175 | ||
|
|
815ddbe94e | ||
|
|
2cb907e731 | ||
|
|
9c52b8b6c5 | ||
|
|
056a15bedb | ||
|
|
18537e3f62 | ||
|
|
24c48bc3ea | ||
|
|
49d4fd44a3 | ||
|
|
c7aef6e467 | ||
|
|
d6338de8bc | ||
|
|
b56b5d9b16 | ||
|
|
90bc242ddd | ||
|
|
f8aa157c93 | ||
|
|
34fbadfd6a | ||
|
|
6d431e5746 | ||
|
|
9e06065fd3 | ||
|
|
09eef604f7 | ||
|
|
5a2dbad343 | ||
|
|
13cb1d3539 | ||
|
|
5a27d50d2a | ||
|
|
ffec32cba8 | ||
|
|
53118a4e0b | ||
|
|
d2f63b8b16 | ||
|
|
0e829974ef | ||
|
|
eb0c61ce6d | ||
|
|
b417cd64a0 | ||
|
|
70b4795650 | ||
|
|
3842b046cd | ||
|
|
c2ad1b8c99 | ||
|
|
bdb4ffe69d | ||
|
|
0a053d32ce | ||
|
|
859819f0f0 | ||
|
|
008d619236 | ||
|
|
b59ecafc83 | ||
|
|
1fef358f55 | ||
|
|
bfcd3e6ff9 | ||
|
|
433a87795c | ||
|
|
a3975f47e2 | ||
|
|
0debaecad6 | ||
|
|
97da4d1d61 | ||
|
|
faf90d1fa7 | ||
|
|
1e23ce1062 | ||
|
|
9ad192054b | ||
|
|
bee3758d18 | ||
|
|
cae7f9bc22 | ||
|
|
138f1d29df | ||
|
|
6c0fc09075 | ||
|
|
2205506b26 | ||
|
|
2e070c9a12 | ||
|
|
52b75e0b06 | ||
|
|
278862127b | ||
|
|
4ffebdac77 | ||
|
|
782faddbf8 | ||
|
|
df532b36ab | ||
|
|
b0cb53ab44 | ||
|
|
580b8cbdb4 | ||
|
|
48ab324100 | ||
|
|
f79bebceeb | ||
|
|
91ba00346c | ||
|
|
7286b21f5a | ||
|
|
134b75568a | ||
|
|
59071424b3 | ||
|
|
f938d08361 | ||
|
|
f78e8a5671 | ||
|
|
4c7faad987 | ||
|
|
bf46008878 | ||
|
|
a37d13f788 | ||
|
|
c68b2e3b06 | ||
|
|
cb8bf2cd89 | ||
|
|
089d8a8f79 | ||
|
|
de9072d506 | ||
|
|
279f8f2a6c | ||
|
|
7a4c9a36b6 | ||
|
|
476f779e76 | ||
|
|
75eb0c307e | ||
|
|
da5d64ad9e | ||
|
|
ad8fe53348 | ||
|
|
94725dfe3c | ||
|
|
e6ce05571f | ||
|
|
cc40e9d6cb | ||
|
|
0f483dc4e1 | ||
|
|
c5abd21569 | ||
|
|
6f7a992847 | ||
|
|
1eff489158 | ||
|
|
dcabb77218 | ||
|
|
67cda575a5 | ||
|
|
195c9e416c | ||
|
|
5db6b2049f | ||
|
|
c9b73a5008 | ||
|
|
56ad86ee60 | ||
|
|
04c14274fd | ||
|
|
bebbc1535b | ||
|
|
1636226572 | ||
|
|
2fbcfc03dd | ||
|
|
ac1fc43250 | ||
|
|
a2dceac62f | ||
|
|
2402769d9d | ||
|
|
7030d6c1ba | ||
|
|
1edc7d3329 | ||
|
|
352ef35ac2 | ||
|
|
f9b008e8e8 | ||
|
|
251259e4bd | ||
|
|
a622f8e86e | ||
|
|
02cdccc77c | ||
|
|
375006deb1 | ||
|
|
9b053de0b7 | ||
|
|
a62c53eb00 | ||
|
|
08d895b2e0 | ||
|
|
eb3ee3a6b2 | ||
|
|
af0124d4e6 | ||
|
|
3d37bc056d | ||
|
|
a25bc0670e | ||
|
|
0f4662265a | ||
|
|
79bb38a098 | ||
|
|
ed1c83fe7f | ||
|
|
b0bd80d8d1 | ||
|
|
9aef1a88ba | ||
|
|
0f80e27978 | ||
|
|
c5fc16b77a | ||
|
|
d5f0691fc3 | ||
|
|
91019b4a51 | ||
|
|
2804f38d4f | ||
|
|
416ac4fbdc | ||
|
|
14e3c258fb | ||
|
|
ce9db575a6 | ||
|
|
1ee80b68ec | ||
|
|
7c7ea1fbc2 | ||
|
|
3378c8e170 | ||
|
|
2fbb490cbb | ||
|
|
e41efba0cd | ||
|
|
7c7b3cdc07 | ||
|
|
78eb512836 | ||
|
|
3dac6aa188 | ||
|
|
4a3d1a1787 | ||
|
|
2cfde7d3f4 | ||
|
|
05e90b59d2 | ||
|
|
02a683f09a | ||
|
|
f61f7429bd | ||
|
|
09f908b019 | ||
|
|
d5cc56756e | ||
|
|
77a355ee8d | ||
|
|
7bcce0b9d9 | ||
|
|
e1602258dc | ||
|
|
78ef3c3f37 | ||
|
|
890d664746 | ||
|
|
a28338df30 | ||
|
|
221fcf77dc | ||
|
|
378b0e93eb | ||
|
|
a69711942b | ||
|
|
0679022f7a | ||
|
|
d497b01c45 | ||
|
|
682c3b64b2 | ||
|
|
9715429ed0 | ||
|
|
ad4d9b9c63 | ||
|
|
85a19f7971 | ||
|
|
6705f638c0 | ||
|
|
618831f1eb | ||
|
|
6287e8c01b | ||
|
|
beb035b3e1 | ||
|
|
5c101b09d4 | ||
|
|
7132136a91 | ||
|
|
03bf93ad13 | ||
|
|
65859924c2 | ||
|
|
97d0a1ce61 | ||
|
|
3fe35344f0 | ||
|
|
bbca5a29b7 | ||
|
|
2a6a816baf | ||
|
|
73f7d5d5f5 | ||
|
|
0871ce345a | ||
|
|
01ddac380f | ||
|
|
4840666664 | ||
|
|
21e4ece669 | ||
|
|
887a628c23 | ||
|
|
2ea876ae4f | ||
|
|
c47c800cfa | ||
|
|
ef9633af35 | ||
|
|
217b86e616 | ||
|
|
37aabc4948 | ||
|
|
e099243437 | ||
|
|
6f238bdbe0 | ||
|
|
77dfd0296c | ||
|
|
1888993113 | ||
|
|
fb28693854 | ||
|
|
7f8c6f2d61 | ||
|
|
15984473b4 | ||
|
|
b03ecf1562 | ||
|
|
fdc5916ada | ||
|
|
a54d351e9c | ||
|
|
62cde57556 | ||
|
|
2bd8037d7b | ||
|
|
a1793efcc0 | ||
|
|
ed2eed5110 | ||
|
|
e50b8c7407 | ||
|
|
ffae3bd868 | ||
|
|
181f9c7a5f | ||
|
|
1d95af5a31 | ||
|
|
d7a4b5b45b | ||
|
|
2e8eed7504 | ||
|
|
d768bfc97a | ||
|
|
9c997ab845 | ||
|
|
c1976ce4d3 | ||
|
|
be74de2b22 | ||
|
|
fda1208660 | ||
|
|
b65f4f2b74 | ||
|
|
530c355787 | ||
|
|
fc21e22afb | ||
|
|
f9bc5c4927 | ||
|
|
484b141328 | ||
|
|
dc0762312e | ||
|
|
33f46be993 | ||
|
|
d1c176cfc8 | ||
|
|
17d14968fa | ||
|
|
df51130fce | ||
|
|
bc05d2c01e | ||
|
|
a0e37c0357 | ||
|
|
a218e7e5f8 | ||
|
|
f2a4386892 | ||
|
|
c9b111a022 | ||
|
|
b9feb50a2c | ||
|
|
7fdf8da8ed | ||
|
|
1dba6208a5 | ||
|
|
9f4422d1b9 | ||
|
|
8bfc3f2945 | ||
|
|
0e1a7e2603 | ||
|
|
cc7fc6a9e1 | ||
|
|
da1e7a0277 | ||
|
|
87ead24e20 | ||
|
|
e05e6325c9 | ||
|
|
b090c8c153 | ||
|
|
3c3dfeb325 | ||
|
|
7ee8cc7fb1 | ||
|
|
912fff9b0f | ||
|
|
2c71385ce7 | ||
|
|
139457087b | ||
|
|
3a26285bd1 | ||
|
|
e2c1deaeb3 | ||
|
|
61baf1a886 | ||
|
|
51e5e7126c | ||
|
|
a53a93ccee | ||
|
|
e980f1f20e | ||
|
|
fac9eab496 | ||
|
|
1b1afcf195 | ||
|
|
788f671626 | ||
|
|
ac7b4c9fcf | ||
|
|
9a4af8ff2e | ||
|
|
9cfd8013d2 | ||
|
|
74f5a0e8ee | ||
|
|
0d67c2588d | ||
|
|
738f501cf9 | ||
|
|
ff6a5d99d6 | ||
|
|
a46a34412c | ||
|
|
db6c3172de | ||
|
|
0d38279950 | ||
|
|
3dd28082ea | ||
|
|
767283cbc6 | ||
|
|
0066902127 | ||
|
|
9a567b875e | ||
|
|
a7f877caf5 | ||
|
|
e75928a774 | ||
|
|
4b7f46852b | ||
|
|
1e0c128ad6 | ||
|
|
e3887129fc | ||
|
|
2eaf882734 | ||
|
|
284c402a49 | ||
|
|
d08eb0e3a9 | ||
|
|
76b7623cb0 | ||
|
|
1e25091698 | ||
|
|
1289f7d4e2 | ||
|
|
eb1b2eb883 | ||
|
|
74e45139bf | ||
|
|
f9a240ade4 | ||
|
|
b09e7f3683 | ||
|
|
b19d52555f | ||
|
|
ab4dd9a4a8 | ||
|
|
a94942a36e | ||
|
|
ab7c51994c | ||
|
|
67967a92cf | ||
|
|
6efa8c5356 | ||
|
|
c28669f5b2 | ||
|
|
270f4a8a12 | ||
|
|
641a169e6f | ||
|
|
25e254bbfb | ||
|
|
af0ddf532a | ||
|
|
eaf76c8dee | ||
|
|
5c0ca7b706 | ||
|
|
530b247c33 | ||
|
|
a5bc86e948 | ||
|
|
9910937269 | ||
|
|
1344c289df | ||
|
|
7f4111c12c | ||
|
|
105fdea8ef | ||
|
|
9d91e3f242 | ||
|
|
fdcb3a5e7f | ||
|
|
86974b76a9 | ||
|
|
835915750c | ||
|
|
fe8a125d1a | ||
|
|
f82e572ad2 | ||
|
|
8aa03496fb | ||
|
|
3c2c347bb9 | ||
|
|
0d166288cc | ||
|
|
f8954ef870 | ||
|
|
66afd4ddac | ||
|
|
a99eb8a44a | ||
|
|
b2981318b0 | ||
|
|
5142f3afd4 | ||
|
|
b7b3601337 | ||
|
|
50e5ca86c6 | ||
|
|
fe9a9a37e7 | ||
|
|
1c5ab42ea6 | ||
|
|
74fcbe426d | ||
|
|
6a65826fd5 | ||
|
|
ad47bfacd4 | ||
|
|
9e9bac997b | ||
|
|
014fbeac71 | ||
|
|
0b214faeca | ||
|
|
abff65a11a | ||
|
|
2f6eed237a | ||
|
|
7253c9bba3 | ||
|
|
a84d3c09e8 | ||
|
|
f5e1f1cf6b | ||
|
|
8096a389da | ||
|
|
1a21850fc4 | ||
|
|
efae5ecd4b | ||
|
|
1e043325d6 | ||
|
|
0bfce5594d | ||
|
|
dbe2787785 | ||
|
|
20e98319af | ||
|
|
2063049747 | ||
|
|
a7def9ce25 | ||
|
|
2418207149 | ||
|
|
5da5967e97 | ||
|
|
45b2bf5b13 | ||
|
|
7527f6c764 | ||
|
|
bdfa1fdeb3 | ||
|
|
79f58cc8d0 | ||
|
|
437d0a37a9 | ||
|
|
0e24a0767b | ||
|
|
91abf56977 | ||
|
|
f0734d86db | ||
|
|
3581d633c1 | ||
|
|
b8895bef33 | ||
|
|
89d0d12559 | ||
|
|
34fe291268 | ||
|
|
4b1e292e1c | ||
|
|
90eb6fd0c3 | ||
|
|
50da8a0f0b | ||
|
|
7dcd328f2e | ||
|
|
e2d66cc605 | ||
|
|
f5fc721b3b | ||
|
|
c4bbb6fa70 | ||
|
|
748aee2cff | ||
|
|
31473d3f49 | ||
|
|
4de727791a | ||
|
|
d1d04d5585 | ||
|
|
6f41a14012 |
14
.env
@@ -16,15 +16,29 @@ LOGO_URL=''
|
||||
LOGO_WHITE_URL=''
|
||||
LOGOUT_URL=null
|
||||
MARKETING_SITE_BASE_URL=''
|
||||
TERMS_OF_SERVICE_URL=''
|
||||
PRIVACY_POLICY_URL=''
|
||||
ORDER_HISTORY_URL=''
|
||||
PUBLISHER_BASE_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=''
|
||||
STUDIO_SHORT_NAME='Studio'
|
||||
SUPPORT_EMAIL=''
|
||||
SUPPORT_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
ENABLE_ACCESSIBILITY_PAGE=false
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=false
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=false
|
||||
INVITE_STUDENTS_EMAIL_TO=''
|
||||
AI_TRANSLATIONS_BASE_URL=''
|
||||
ENABLE_CHECKLIST_QUALITY=''
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
NODE_ENV='development'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:2001'
|
||||
BASE_URL='http://localhost:2001'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL=
|
||||
@@ -16,17 +16,31 @@ LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
TERMS_OF_SERVICE_URL=
|
||||
PRIVACY_POLICY_URL=
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
PORT=2001
|
||||
PUBLISHER_BASE_URL=
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
SITE_NAME='Your Plaform Name Here'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
SUPPORT_EMAIL='support@example.com'
|
||||
STUDIO_SHORT_NAME='Studio'
|
||||
SUPPORT_EMAIL=
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_ACCESSIBILITY_PAGE=false
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=true
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:2001'
|
||||
BASE_URL='http://localhost:2001'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
@@ -22,10 +22,17 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
STUDIO_SHORT_NAME='Studio'
|
||||
SUPPORT_EMAIL='support@example.com'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_UNIT_PAGE=true
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
|
||||
29
.eslintrc.js
@@ -1,6 +1,9 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const path = require('path');
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('eslint',
|
||||
module.exports = createConfig(
|
||||
'eslint',
|
||||
{
|
||||
rules: {
|
||||
'jsx-a11y/label-has-associated-control': [2, {
|
||||
@@ -8,6 +11,24 @@ module.exports = createConfig('eslint',
|
||||
}],
|
||||
'template-curly-spacing': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
indent: 'off',
|
||||
indent: ['error', 2],
|
||||
'no-restricted-exports': 'off',
|
||||
},
|
||||
});
|
||||
settings: {
|
||||
// Import URLs should be resolved using aliases
|
||||
'import/resolver': {
|
||||
webpack: {
|
||||
config: path.resolve(__dirname, 'webpack.dev.config.js'),
|
||||
},
|
||||
},
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['plugins/**/*.test.jsx'],
|
||||
rules: {
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
27
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
## Description
|
||||
|
||||
Describe what this pull request changes, and why. Include implications for people using this change.
|
||||
Design decisions and their rationales should be documented in the repo (docstring / ADR), per
|
||||
|
||||
Useful information to include:
|
||||
- Which edX user roles will this change impact? Common user roles are "Learner", "Course Author",
|
||||
"Developer", and "Operator".
|
||||
- Include screenshots for changes to the UI (ideally, both "before" and "after" screenshots, if applicable).
|
||||
- Provide links to the description of corresponding configuration changes. Remember to correctly annotate these
|
||||
changes.
|
||||
|
||||
## Supporting information
|
||||
|
||||
Link to other information about the change, such as Jira issues, GitHub issues, or Discourse discussions.
|
||||
Be sure to check they are publicly readable, or if not, repeat the information here.
|
||||
|
||||
## Testing instructions
|
||||
|
||||
Please provide detailed step-by-step instructions for testing this change.
|
||||
|
||||
|
||||
## Other information
|
||||
|
||||
Include anything else that will help reviewers and consumers understand the change.
|
||||
- Does this change depend on other changes elsewhere?
|
||||
- Any special concerns or limitations? For example: deprecations, migrations, security, or accessibility.
|
||||
@@ -16,4 +16,4 @@ jobs:
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
|
||||
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "label: " it tries to apply
|
||||
# the label indicated in rest of comment.
|
||||
# If the comment starts with "remove label: ", it tries
|
||||
# to remove the indicated label.
|
||||
# Note: Labels are allowed to have spaces and this script does
|
||||
# not parse spaces (as often a space is legitimate), so the command
|
||||
# "label: really long lots of words label" will apply the
|
||||
# label "really long lots of words label"
|
||||
|
||||
name: Allows for the adding and removing of labels via comment
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
add_remove_labels:
|
||||
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
|
||||
|
||||
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
|
||||
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "assign me" it assigns the author to the
|
||||
# ticket (case insensitive)
|
||||
|
||||
name: Assign comment author to ticket if they say "assign me"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
self_assign_by_comment:
|
||||
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master
|
||||
12
.github/workflows/update-browserslist-db.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
name: Update Browserslist DB
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-browserslist:
|
||||
uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
|
||||
|
||||
secrets:
|
||||
requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}
|
||||
11
.github/workflows/validate.yml
vendored
@@ -9,14 +9,13 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
|
||||
6
.gitignore
vendored
@@ -20,3 +20,9 @@ temp/babel-plugin-react-intl
|
||||
/temp
|
||||
/.vscode
|
||||
/module.config.js
|
||||
|
||||
# Local environment overrides
|
||||
.env.private
|
||||
|
||||
# Messages .json files fetched by atlas
|
||||
src/i18n/messages/
|
||||
|
||||
34
.stylelintrc.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"extends": ["@edx/stylelint-config-edx"],
|
||||
"rules": {
|
||||
"selector-pseudo-class-no-unknown": [true, {
|
||||
"ignorePseudoClasses": ["export"]
|
||||
}],
|
||||
"unit-no-unknown": [true, {
|
||||
"ignoreUnits": ["\\.5"]
|
||||
}],
|
||||
"property-no-vendor-prefix": [true, {
|
||||
"ignoreProperties": ["animation", "filter", "transform", "transition"]
|
||||
}],
|
||||
"value-no-vendor-prefix": [true, {
|
||||
"ignoreValues": ["fill-available"]
|
||||
}],
|
||||
"function-no-unknown": null,
|
||||
"number-leading-zero": "never",
|
||||
"no-descending-specificity": null,
|
||||
"selector-class-pattern": null,
|
||||
"scss/no-global-function-names": null,
|
||||
"color-hex-case": "upper",
|
||||
"color-hex-length": "long",
|
||||
"scss/dollar-variable-empty-line-before": null,
|
||||
"scss/dollar-variable-colon-space-after": "at-least-one-space",
|
||||
"at-rule-no-unknown": null,
|
||||
"scss/at-rule-no-unknown": true,
|
||||
"scss/at-import-partial-extension": null,
|
||||
"scss/comment-no-empty": null,
|
||||
"property-no-unknown": [true, {
|
||||
"ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"]
|
||||
}],
|
||||
"alpha-value-notation": "number"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[o:open-edx:p:edx-platform:r:frontend-app-course-authoring]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
|
||||
2
CODEOWNERS
Normal file
@@ -0,0 +1,2 @@
|
||||
# The following users are the maintainers of all frontend-app-course-authoring files
|
||||
* @openedx/2u-tnl
|
||||
37
Makefile
Executable file → Normal file
@@ -1,22 +1,17 @@
|
||||
transifex_resource = frontend-app-course-authoring
|
||||
export TRANSIFEX_RESOURCE = ${transifex_resource}
|
||||
transifex_langs = "ar,fr,es_419,zh_CN"
|
||||
|
||||
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
|
||||
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
|
||||
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
|
||||
|
||||
# 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...
|
||||
@@ -34,20 +29,19 @@ 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
|
||||
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -f --mode reviewed --languages=$(transifex_langs)
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull $(ATLAS_OPTIONS) \
|
||||
translations/frontend-component-ai-translations/src/i18n/messages:frontend-component-ai-translations \
|
||||
translations/frontend-lib-content-components/src/i18n/messages:frontend-lib-content-components \
|
||||
translations/frontend-platform/src/i18n/messages:frontend-platform \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring
|
||||
|
||||
$(intl_imports) frontend-component-ai-translations frontend-lib-content-components frontend-platform paragon frontend-component-footer frontend-app-course-authoring
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
@@ -59,6 +53,7 @@ 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
|
||||
|
||||
|
||||
378
README.rst
@@ -1,39 +1,300 @@
|
||||
|Build Status| |Codecov| |license|
|
||||
|
||||
frontend-app-course-authoring
|
||||
=============================
|
||||
#############################
|
||||
|
||||
Please tag `@edx/teaching-and-learning <https://github.com/orgs/edx/teams/teaching-and-learning>`_ on any PRs or issues. Thanks.
|
||||
|license-badge| |status-badge| |codecov-badge|
|
||||
|
||||
Prerequisite
|
||||
------------
|
||||
|
||||
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
|
||||
Purpose
|
||||
*******
|
||||
|
||||
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
|
||||
|
||||
Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below.
|
||||
|
||||
|
||||
Getting Started
|
||||
************
|
||||
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
The `devstack`_ is currently recommended as a development environment for your
|
||||
new MFE. If you start it with ``make dev.up.lms`` that should give you
|
||||
everything you need as a companion to this frontend.
|
||||
|
||||
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ to get started using it.
|
||||
|
||||
.. _Devstack: https://github.com/openedx/devstack
|
||||
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
All features that integrate into the edx-platform CMS require that the ``COURSE_AUTHORING_MICROFRONTEND_URL`` Django setting is set in the CMS environment and points to this MFE's deployment URL. This should be done automatically if you are using devstack or tutor-mfe.
|
||||
|
||||
Cloning and Startup
|
||||
===================
|
||||
|
||||
Installation and Startup
|
||||
------------------------
|
||||
|
||||
1. Clone the repo:
|
||||
|
||||
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
|
||||
|
||||
2. Install npm dependencies:
|
||||
2. Use node v18.x.
|
||||
|
||||
The current version of the micro-frontend build scripts support node 18.
|
||||
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 use`_.
|
||||
|
||||
3. Install npm dependencies:
|
||||
|
||||
``cd frontend-app-course-authoring && npm install``
|
||||
|
||||
3. Start the dev server:
|
||||
|
||||
4. Start the dev server:
|
||||
|
||||
``npm start``
|
||||
|
||||
|
||||
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
|
||||
or whatever port you setup.
|
||||
|
||||
|
||||
Features
|
||||
********
|
||||
|
||||
Feature: Pages and Resources Studio Tab
|
||||
=======================================
|
||||
|
||||
Enables a "Pages & Resources" menu item in Studio, under the "Content" menu.
|
||||
|
||||
.. image:: ./docs/readme-images/feature-pages-resources.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
The following are requirements for this feature to function correctly:
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``discussions.pages_and_resources_mfe``: must be enabled for the set of users meant to access this feature.
|
||||
|
||||
* `frontend-app-learning <https://github.com/openedx/frontend-app-learning>`_: This MFE expects it to be the LMS frontend.
|
||||
* `frontend-app-discussions <https://github.com/openedx/frontend-app-discussions/>`_: This is what the "Discussions" configuration provided by this feature actually configures. Without it, discussion settings are ignored.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
In additional to the standard settings, the following local configuration items are required:
|
||||
|
||||
* ``LEARNING_BASE_URL``: points to Learning MFE; necessary so that the `View Live` button works
|
||||
* ``ENABLE_PROGRESS_GRAPH_SETTINGS``: allow enabling or disabling the learner progress graph course-wide
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
Clicking on the "Pages & Resources" menu item takes the user to the course's ``pages-and-resources`` standalone page in this MFE. (In a devstack, for instance: http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources.)
|
||||
|
||||
UX-wise, **Pages & Resources** is meant to look like a Studio tab, so reproduces Studio's header.
|
||||
|
||||
For a particular course, this page allows one to:
|
||||
|
||||
* Configure the new Discussions MFE (making this a requirement for it). This includes:
|
||||
|
||||
* Enabling/disabling the feature entirely
|
||||
* Picking a different discussion provider, while showing a comparison matrix between them:
|
||||
|
||||
* edX
|
||||
* Ed Discussion
|
||||
* InScribe
|
||||
* Piazza
|
||||
* Yellowdig
|
||||
|
||||
* Allowing to configure the selected provider
|
||||
|
||||
* Enable/Disable learner progress
|
||||
* Enable/Disable learner notes
|
||||
* Enable/Disable the learner wiki
|
||||
* Enable/Disable the LMS calculator
|
||||
* Go to the textbook management page in Studio (in a devstack: http://localhost:18010/textbooks/course-v1:edX+DemoX+Demo_Course)
|
||||
* Go to the custom page management page in Studio(in a devstack http://localhost:18010/tabs/course-v1:edX+DemoX+Demo_Course)
|
||||
|
||||
Feature: New React XBlock Editors
|
||||
=================================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-problem-editor.png
|
||||
|
||||
This allows an operator to enable the use of new React editors for the HTML, Video, and Problem XBlocks, all of which are provided here.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``new_core_editors.use_new_text_editor``: must be enabled for the new HTML Xblock editor to be used in Studio
|
||||
* ``new_core_editors.use_new_video_editor``: must be enabled for the new Video Xblock editor to be used in Studio
|
||||
* ``new_core_editors.use_new_problem_editor``: must be enabled for the new Problem Xblock editor to be used in Studio
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
In additional to the standard settings, the following local configuration item is required:
|
||||
|
||||
* ``ENABLE_NEW_EDITOR_PAGES``: must be enabled in order to actually present the new XBlock editors (on by default)
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
When a corresponding waffle flag is set, upon editing a block in Studio, the view is rendered by this MFE instead of by the XBlock's authoring view. The user remains in Studio.
|
||||
|
||||
.. note::
|
||||
|
||||
The new editors themselves are currently implemented in a repository outside ``openedx``: `frontend-lib-content-components <https://github.com/edx/frontend-lib-content-components/>`_, a dependency of this MFE. This repository is slated to be moved to the ``openedx`` org, however.
|
||||
|
||||
Feature: New Proctoring Exams View
|
||||
==================================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-proctored-exams.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Django settings:
|
||||
|
||||
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
|
||||
|
||||
* ``edx-platform`` Feature flags:
|
||||
|
||||
* ``ENABLE_EXAM_SETTINGS_HTML_VIEW``: this feature flag must be enabled for the link to the settings view to be shown
|
||||
|
||||
* `edx-exams <https://github.com/edx/edx-exams>`_: for this feature to work, the ``edx-exams`` IDA must be deployed and its API accessible by the browser
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
In additional to the standard settings, the following local configuration item is required:
|
||||
|
||||
* ``EXAMS_BASE_URL``: URL to the ``edx-exams`` deployment
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
In Studio, a new item ("Proctored Exam Settings") is added to "Other Course Settings" in the course's "Certificates" settings page. When clicked, this takes the author to the corresponding page in the Course Authoring MFE, where one can:
|
||||
|
||||
* Enable proctored exams for the course
|
||||
* Allow opting out of proctored exams
|
||||
* Select a proctoring provider
|
||||
* Enable automatic creation of Zendesk tickets for "suspicious" proctored exam attempts
|
||||
|
||||
Feature: Advanced Settings
|
||||
==========================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-advanced-settings.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_advanced_settings_page``: this feature flag must be enabled for the link to the settings view to be shown. It can be enabled on a per-course basis.
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
In Studio, the "Advanced Settings" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. The advanced settings page holds many different settings for the course, such as what features or XBlocks are enabled.
|
||||
|
||||
Feature: Files & Uploads
|
||||
==========================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-files-uploads.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_files_uploads_page``: this feature flag must be enabled for the link to the Files & Uploads page to go to the MFE. It can be enabled on a per-course basis.
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
In Studio, the "Files & Uploads" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. This page allows managing static asset files like PDFs, images, etc. used for the course.
|
||||
|
||||
Feature: Course Updates
|
||||
==========================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-course-updates.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_updates_page``: this feature flag must be enabled.
|
||||
|
||||
Feature: Import/Export Pages
|
||||
============================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-export.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_export_page``: this feature flag will change the CMS to link to the new export page.
|
||||
* ``contentstore.new_studio_mfe.use_new_import_page``: this feature flag will change the CMS to link to the new import page.
|
||||
|
||||
Feature: Tagging/Taxonomy Pages
|
||||
================================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-tagging-taxonomy-pages.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``new_studio_mfe.use_tagging_taxonomy_list_page``: this feature flag must be enabled.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
In additional to the standard settings, the following local configuration items are required:
|
||||
|
||||
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled in order to actually present the new Tagging/Taxonomy pages.
|
||||
|
||||
|
||||
Developing
|
||||
**********
|
||||
|
||||
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
|
||||
|
||||
|
||||
If your devstack includes the default Demo course, you can visit the following URLs to see content:
|
||||
|
||||
- `Proctored Exam Settings <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/proctored-exam-settings>`_
|
||||
- `Pages and Resources <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources>`_ (work in progress)
|
||||
- `Pages and Resources <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources>`_
|
||||
|
||||
Troubleshooting
|
||||
========================
|
||||
|
||||
* ``npm ERR! gyp ERR! build error`` while running npm install on Macs with M1 processors: Probably due to a compatibility issue of node-canvas with M1.
|
||||
|
||||
Run ``brew install pkg-config pixman cairo pango libpng jpeg giflib librsvg`` before ``npm install`` to get the correct versions of the dependencies.
|
||||
If there is still an error, look for "no package [...] found" in the error message and install missing package via brew.
|
||||
(https://github.com/Automattic/node-canvas/issues/1733)
|
||||
|
||||
|
||||
Deploying
|
||||
*********
|
||||
|
||||
Production Build
|
||||
----------------
|
||||
================
|
||||
|
||||
The production build is created with ``npm run build``.
|
||||
|
||||
@@ -43,3 +304,92 @@ The production build is created with ``npm run build``.
|
||||
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
|
||||
:target: @edx/frontend-app-course-authoring
|
||||
|
||||
Internationalization
|
||||
====================
|
||||
|
||||
Please see refer to the `frontend-platform i18n howto`_ for documentation on
|
||||
internationalization.
|
||||
|
||||
.. _frontend-platform i18n howto: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
|
||||
|
||||
|
||||
Getting Help
|
||||
************
|
||||
|
||||
If you're having trouble, we have discussion forums at
|
||||
https://discuss.openedx.org 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-course-authoring/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
|
||||
|
||||
|
||||
License
|
||||
*******
|
||||
|
||||
The code in this repository is licensed under the AGPLv3 unless otherwise
|
||||
noted.
|
||||
|
||||
Please see `LICENSE <LICENSE>`_ for details.
|
||||
|
||||
|
||||
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 have a discussion about your new feature idea with the maintainers prior to
|
||||
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/
|
||||
|
||||
People
|
||||
******
|
||||
|
||||
The assigned maintainers for this component and other project details may be
|
||||
found in `Backstage`_. Backstage pulls this data from the ``catalog-info.yaml``
|
||||
file in this repo.
|
||||
|
||||
.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/frontend-app-course-authoring
|
||||
|
||||
|
||||
Reporting Security Issues
|
||||
*************************
|
||||
|
||||
Please do not report security issues in public, and email security@openedx.org instead.
|
||||
|
||||
.. |license-badge| image:: https://img.shields.io/github/license/openedx/frontend-app-course-authoring.svg
|
||||
:target: https://github.com/openedx/frontend-app-course-authoring/blob/master/LICENSE
|
||||
:alt: License
|
||||
|
||||
.. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen
|
||||
|
||||
.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-course-authoring/coverage.svg?branch=master
|
||||
:target: https://codecov.io/github/openedx/frontend-app-course-authoring?branch=master
|
||||
:alt: Codecov
|
||||
|
||||
18
catalog-info.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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-course-authoring'
|
||||
description: "The frontend (MFE) for Open edX Course Authoring (aka Studio)"
|
||||
links:
|
||||
- url: "https://github.com/openedx/frontend-app-course-authoring"
|
||||
title: "Frontend app course authoring"
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
spec:
|
||||
owner: group:2u-tnl
|
||||
type: 'website'
|
||||
lifecycle: 'production'
|
||||
@@ -8,3 +8,6 @@ coverage:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 0%
|
||||
ignore:
|
||||
- "src/grading-settings/grading-scale/react-ranger.js"
|
||||
- "src/index.js"
|
||||
|
||||
BIN
docs/readme-images/feature-advanced-settings.png
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
docs/readme-images/feature-course-updates.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
docs/readme-images/feature-export.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
docs/readme-images/feature-files-uploads.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
docs/readme-images/feature-pages-resources.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
docs/readme-images/feature-problem-editor.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/readme-images/feature-proctored-exams.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/readme-images/feature-tagging-taxonomy-pages.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
@@ -1,17 +1,19 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFilesAfterEnv: [
|
||||
'jest-expect-message',
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
'src/setupTest.js',
|
||||
'src/i18n',
|
||||
],
|
||||
snapshotSerializers: [
|
||||
'enzyme-to-json/serializer',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^lodash-es$': 'lodash',
|
||||
'^CourseAuthoring/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
modulePathIgnorePatterns: [
|
||||
'/src/pages-and-resources/utils.test.jsx',
|
||||
],
|
||||
});
|
||||
|
||||
40905
package-lock.json
generated
112
package.json
@@ -11,12 +11,15 @@
|
||||
],
|
||||
"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 --ext .js --ext .jsx . --fix",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"i18n_extract": "fedx-scripts formatjs extract",
|
||||
"stylelint": "stylelint \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
|
||||
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
"start:with-theme": "paragon install-theme && npm start && npm install",
|
||||
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
|
||||
"types": "tsc --noEmit"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
@@ -33,52 +36,87 @@
|
||||
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-build": "^11.0.0",
|
||||
"@edx/frontend-component-footer": "11.1.1",
|
||||
"@edx/frontend-lib-content-components": "^1.43.0",
|
||||
"@edx/frontend-platform": "2.5.1",
|
||||
"@edx/paragon": "20.6.1",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.28",
|
||||
"@fortawesome/free-brands-svg-icons": "5.11.2",
|
||||
"@fortawesome/free-regular-svg-icons": "5.11.2",
|
||||
"@fortawesome/free-solid-svg-icons": "5.11.2",
|
||||
"@fortawesome/react-fontawesome": "0.1.9",
|
||||
"@reduxjs/toolkit": "1.5.0",
|
||||
"@datadog/browser-rum": "^5.13.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-ai-translations": "^2.0.0",
|
||||
"@edx/frontend-component-footer": "^13.0.2",
|
||||
"@edx/frontend-component-header": "^5.0.2",
|
||||
"@edx/frontend-enterprise-hotjar": "^2.0.0",
|
||||
"@edx/frontend-lib-content-components": "^2.1.4",
|
||||
"@edx/frontend-platform": "7.0.1",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@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.2.0",
|
||||
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
||||
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
|
||||
"@openedx-plugins/course-app-ora_settings": "file:plugins/course-apps/ora_settings",
|
||||
"@openedx-plugins/course-app-proctoring": "file:plugins/course-apps/proctoring",
|
||||
"@openedx-plugins/course-app-progress": "file:plugins/course-apps/progress",
|
||||
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
|
||||
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
|
||||
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
|
||||
"@openedx/paragon": "^21.5.7",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tanstack/react-query": "4.36.1",
|
||||
"broadcast-channel": "^7.0.0",
|
||||
"classnames": "2.2.6",
|
||||
"core-js": "3.8.1",
|
||||
"email-validator": "2.0.4",
|
||||
"file-saver": "^2.0.5",
|
||||
"formik": "2.2.6",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"moment": "2.29.2",
|
||||
"moment": "2.29.4",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"react": "17.0.2",
|
||||
"react-datepicker": "^4.13.0",
|
||||
"react-dom": "17.0.2",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-redux": "7.1.3",
|
||||
"react-responsive": "8.1.0",
|
||||
"react-router": "5.1.2",
|
||||
"react-router-dom": "5.1.2",
|
||||
"react-transition-group": "4.4.1",
|
||||
"react-redux": "7.2.9",
|
||||
"react-responsive": "9.0.2",
|
||||
"react-router": "6.16.0",
|
||||
"react-router-dom": "6.16.0",
|
||||
"react-select": "5.8.0",
|
||||
"react-textarea-autosize": "^8.4.1",
|
||||
"react-transition-group": "4.4.5",
|
||||
"redux": "4.0.5",
|
||||
"regenerator-runtime": "0.13.7",
|
||||
"universal-cookie": "^4.0.4",
|
||||
"uuid": "^3.4.0",
|
||||
"yup": "0.31.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.0.0",
|
||||
"@edx/frontend-build": "^11.0.0",
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/react-unit-test-utils": "^2.0.0",
|
||||
"@edx/reactifex": "^1.0.3",
|
||||
"@testing-library/jest-dom": "5.16.4",
|
||||
"@testing-library/react": "12.1.1",
|
||||
"@edx/stylelint-config-edx": "2.3.0",
|
||||
"@edx/typescript-config": "^1.0.1",
|
||||
"@openedx/frontend-build": "13.0.27",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"axios-mock-adapter": "1.20.0",
|
||||
"enzyme": "3.11.0",
|
||||
"enzyme-adapter-react-16": "1.15.6",
|
||||
"enzyme-to-json": "^3.6.2",
|
||||
"glob": "7.1.6",
|
||||
"husky": "3.1.0",
|
||||
"react-test-renderer": "16.9.0",
|
||||
"reactifex": "1.1.1"
|
||||
"axios": "^0.27.2",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.8",
|
||||
"glob": "7.2.3",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"react-test-renderer": "17.0.2",
|
||||
"reactifex": "1.1.1",
|
||||
"ts-loader": "^9.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"decode-uri-component": ">=0.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
function CalculatorSettings({ intl, onClose }) {
|
||||
/**
|
||||
* Settings widget for the "calculator" Course App.
|
||||
* @param {{onClose: () => void}} props
|
||||
*/
|
||||
const CalculatorSettings = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId="calculator"
|
||||
@@ -17,11 +22,10 @@ function CalculatorSettings({ intl, onClose }) {
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
CalculatorSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CalculatorSettings);
|
||||
export default CalculatorSettings;
|
||||
17
plugins/course-apps/calculator/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-calculator",
|
||||
"version": "0.1.0",
|
||||
"description": "Calculator configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
function NotesSettings({ intl, onClose }) {
|
||||
/**
|
||||
* Settings widget for the "edxnotes" Course App.
|
||||
* @param {{onClose: () => void}} props
|
||||
*/
|
||||
const NotesSettings = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId="edxnotes"
|
||||
@@ -17,11 +22,10 @@ function NotesSettings({ intl, onClose }) {
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
NotesSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(NotesSettings);
|
||||
export default NotesSettings;
|
||||
17
plugins/course-apps/edxnotes/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-edxnotes",
|
||||
"version": "0.1.0",
|
||||
"description": "edxnotes configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
63
plugins/course-apps/learning_assistant/Settings.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@openedx/paragon';
|
||||
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const LearningAssistantSettings = ({ onClose }) => {
|
||||
const appId = 'learning_assistant';
|
||||
const appInfo = useModel('courseApps', appId);
|
||||
const intl = useIntl();
|
||||
|
||||
// We need to render more than one link, so we use the bodyChildren prop.
|
||||
const bodyChildren = (
|
||||
appInfo?.documentationLinks?.learnMoreOpenaiDataPrivacy && appInfo?.documentationLinks?.learnMoreOpenai
|
||||
? (
|
||||
<div className="d-flex flex-column">
|
||||
{appInfo.documentationLinks?.learnMoreOpenaiDataPrivacy && (
|
||||
<Hyperlink
|
||||
className="text-primary-500"
|
||||
destination={appInfo.documentationLinks.learnMoreOpenaiDataPrivacy}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{intl.formatMessage(messages.learningAssistantOpenAIDataPrivacyLink)}
|
||||
</Hyperlink>
|
||||
)}
|
||||
{appInfo.documentationLinks?.learnMoreOpenai && (
|
||||
<Hyperlink
|
||||
className="text-primary-500"
|
||||
destination={appInfo.documentationLinks.learnMoreOpenai}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{intl.formatMessage(messages.learningAssistantOpenAILink)}
|
||||
</Hyperlink>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId={appId}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableLearningAssistantHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableLearningAssistantLabel)}
|
||||
bodyChildren={bodyChildren}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
LearningAssistantSettings.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default LearningAssistantSettings;
|
||||
59
plugins/course-apps/learning_assistant/Settings.test.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import { render } from 'CourseAuthoring/pages-and-resources/utils.test';
|
||||
import LearningAssistantSettings from './Settings';
|
||||
|
||||
const onClose = () => { };
|
||||
|
||||
describe('Learning Assistant Settings', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders', async () => {
|
||||
const initialState = {
|
||||
models: {
|
||||
courseApps: {
|
||||
learning_assistant:
|
||||
{
|
||||
id: 'learning_assistant',
|
||||
enabled: true,
|
||||
name: 'Learning Assistant',
|
||||
description: 'Learning Assistant description',
|
||||
allowedOperations: {
|
||||
configure: false,
|
||||
enable: true,
|
||||
},
|
||||
documentationLinks: {
|
||||
learnMoreOpenaiDataPrivacy: 'www.example.com/learn-more-data-privacy',
|
||||
learnMoreOpenai: 'www.example.com/learn-more',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pagesAndResources: {
|
||||
loadingStatus: RequestStatus.SUCCESSFUL,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<LearningAssistantSettings
|
||||
onClose={onClose}
|
||||
/>,
|
||||
{
|
||||
preloadedState: initialState,
|
||||
},
|
||||
);
|
||||
|
||||
const toggleDescription = 'Reinforce learning concepts by sharing text-based course content '
|
||||
+ 'with OpenAI (via API) to power an in-course Learning Assistant. Learners can leave feedback about the quality '
|
||||
+ 'of the AI-powered experience for use by edX to improve the performance of the tool.';
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Configure Learning Assistant' })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText(toggleDescription)).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('Learn more about how OpenAI handles data')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('Learn more about OpenAI API data privacy')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
28
plugins/course-apps/learning_assistant/messages.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.pages-resources.learning-assistant.heading',
|
||||
defaultMessage: 'Configure Learning Assistant',
|
||||
},
|
||||
enableLearningAssistantLabel: {
|
||||
id: 'course-authoring.pages-resources.learning_assistant.enable-learning-assistant.label',
|
||||
defaultMessage: 'Learning Assistant',
|
||||
},
|
||||
enableLearningAssistantHelp: {
|
||||
id: 'course-authoring.pages-resources.learning_assistant.enable-learning-assistant.help',
|
||||
defaultMessage: `Reinforce learning concepts by sharing text-based course content with OpenAI (via API) to power
|
||||
an in-course Learning Assistant. Learners can leave feedback about the quality of the AI-powered experience for
|
||||
use by edX to improve the performance of the tool.`,
|
||||
},
|
||||
learningAssistantOpenAILink: {
|
||||
id: 'course-authoring.pages-resources.learning_assistant.open-ai.link',
|
||||
defaultMessage: 'Learn more about how OpenAI handles data',
|
||||
},
|
||||
learningAssistantOpenAIDataPrivacyLink: {
|
||||
id: 'course-authoring.pages-resources.learning_assistant.open-ai.data-privacy.link',
|
||||
defaultMessage: 'Learn more about OpenAI API data privacy',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
19
plugins/course-apps/learning_assistant/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-learning_assistant",
|
||||
"version": "0.1.0",
|
||||
"description": "Learning Assistant configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Form, Hyperlink } from '@edx/paragon';
|
||||
import { Form, Hyperlink } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import messages from './messages';
|
||||
import { providerNames, bbbPlanTypes } from './constants';
|
||||
import AppConfigFormDivider from '../discussions/app-config-form/apps/shared/AppConfigFormDivider';
|
||||
import LiveCommonFields from './LiveCommonFields';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
|
||||
function BbbSettings({
|
||||
import { providerNames, bbbPlanTypes } from './constants';
|
||||
import LiveCommonFields from './LiveCommonFields';
|
||||
import messages from './messages';
|
||||
|
||||
const BbbSettings = ({
|
||||
intl,
|
||||
values,
|
||||
setFieldValue,
|
||||
}) {
|
||||
}) => {
|
||||
const [bbbPlan, setBbbPlan] = useState(values.tierType);
|
||||
|
||||
useEffect(() => {
|
||||
setBbbPlan(values.tierType);
|
||||
}, [values.tierType]);
|
||||
|
||||
const appInfo = useModel('courseApps', 'live');
|
||||
const app = useModel('liveApps', 'big_blue_button');
|
||||
const isPiiDisabled = !values.piiSharingEnable;
|
||||
function getBbbPlanOptions() {
|
||||
@@ -71,7 +72,7 @@ function BbbSettings({
|
||||
</Form.Group>
|
||||
|
||||
<Hyperlink
|
||||
destination={appInfo.documentationLinks.learnMoreConfiguration}
|
||||
destination={getConfig().BBB_LEARN_MORE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
showLaunchIcon
|
||||
@@ -88,11 +89,19 @@ function BbbSettings({
|
||||
) : (
|
||||
<>
|
||||
{bbbPlan === bbbPlanTypes.commercial && <LiveCommonFields values={values} />}
|
||||
{bbbPlan === bbbPlanTypes.free
|
||||
&& (
|
||||
<p data-testid="free-plan-message">
|
||||
{bbbPlan === bbbPlanTypes.free && (
|
||||
<span data-testid="free-plan-message">
|
||||
{intl.formatMessage(messages.freePlanMessage)}
|
||||
</p>
|
||||
<Hyperlink
|
||||
destination="https://bigbluebutton.org/privacy-policy/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
showLaunchIcon
|
||||
className="text-gray-700 ml-1"
|
||||
>
|
||||
{intl.formatMessage(messages.privacyPolicy)}
|
||||
</Hyperlink>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -100,7 +109,7 @@ function BbbSettings({
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
BbbSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
@@ -7,16 +7,18 @@ import {
|
||||
} from '@testing-library/react';
|
||||
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Switch } from 'react-router-dom';
|
||||
import { initializeMockApp, history } from '@edx/frontend-platform';
|
||||
import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider, PageRoute } from '@edx/frontend-platform/react';
|
||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import initializeStore from '../../store';
|
||||
import { executeThunk } from '../../utils';
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
|
||||
import LiveSettings from './Settings';
|
||||
import {
|
||||
generateLiveConfigurationApiResponse,
|
||||
@@ -24,11 +26,9 @@ import {
|
||||
initialState,
|
||||
configurationProviders,
|
||||
} from './factories/mockApiResponses';
|
||||
|
||||
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
|
||||
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
|
||||
import messages from './messages';
|
||||
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
|
||||
|
||||
let axiosMock;
|
||||
let container;
|
||||
@@ -41,13 +41,13 @@ ReactDOM.createPortal = jest.fn(node => node);
|
||||
const renderComponent = () => {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<PagesAndResourcesProvider courseId={courseId}>
|
||||
<Switch>
|
||||
<PageRoute path={liveSettingsUrl}>
|
||||
<LiveSettings onClose={() => {}} />
|
||||
</PageRoute>
|
||||
</Switch>
|
||||
<MemoryRouter initialEntries={[liveSettingsUrl]}>
|
||||
<Routes>
|
||||
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PagesAndResourcesProvider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
@@ -56,11 +56,11 @@ const renderComponent = () => {
|
||||
};
|
||||
|
||||
const mockStore = async ({
|
||||
usernameSharing = false,
|
||||
emailSharing = false,
|
||||
enabled = true,
|
||||
piiSharingAllowed = true,
|
||||
isFreeTier = false,
|
||||
usernameSharing = false,
|
||||
emailSharing = false,
|
||||
enabled = true,
|
||||
piiSharingAllowed = true,
|
||||
isFreeTier = false,
|
||||
}) => {
|
||||
const fetchProviderConfigUrl = `${providersApiUrl}/${courseId}/`;
|
||||
const fetchLiveConfigUrl = `${providerConfigurationApiUrl}/${courseId}/`;
|
||||
@@ -84,7 +84,6 @@ describe('BBB Settings', () => {
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
history.push(liveSettingsUrl);
|
||||
});
|
||||
|
||||
test('Plan dropdown to be visible and enabled in UI', async () => {
|
||||
@@ -107,7 +106,8 @@ describe('BBB Settings', () => {
|
||||
expect(getAllByRole(dropDown, 'option').length).toBe(noOfOptions);
|
||||
});
|
||||
|
||||
test('Connect to support and PII sharing message is visible and plans selection is disabled, When pii sharing is disabled, ',
|
||||
test(
|
||||
'Connect to support and PII sharing message is visible and plans selection is disabled, When pii sharing is disabled, ',
|
||||
async () => {
|
||||
await mockStore({ piiSharingAllowed: false });
|
||||
renderComponent();
|
||||
@@ -120,7 +120,8 @@ describe('BBB Settings', () => {
|
||||
);
|
||||
expect(helpRequestPiiText).toHaveTextContent(messages.piiSharingEnableHelpTextBbb.defaultMessage);
|
||||
expect(container.querySelector('select[name="tierType"]')).toBeDisabled();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('free plans message is visible when free plan is selected', async () => {
|
||||
await mockStore({ emailSharing: true, isFreeTier: true });
|
||||
@@ -130,7 +131,7 @@ describe('BBB Settings', () => {
|
||||
const dropDown = container.querySelector('select[name="tierType"]');
|
||||
userEvent.selectOptions(
|
||||
dropDown,
|
||||
getByRole(dropDown, 'option', { name: 'Free' }),
|
||||
getByRole(dropDown, 'option', { name: 'Free' }),
|
||||
);
|
||||
expect(queryByTestId(container, 'free-plan-message')).toBeInTheDocument();
|
||||
expect(queryByTestId(container, 'free-plan-message')).toHaveTextContent(messages.freePlanMessage.defaultMessage);
|
||||
48
plugins/course-apps/live/LiveCommonFields.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const LiveCommonFields = ({
|
||||
intl,
|
||||
values,
|
||||
}) => (
|
||||
<>
|
||||
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
|
||||
<FormikControl
|
||||
name="consumerKey"
|
||||
value={values.consumerKey}
|
||||
floatingLabel={intl.formatMessage(messages.consumerKey)}
|
||||
className="pb-1"
|
||||
type="input"
|
||||
/>
|
||||
<FormikControl
|
||||
name="consumerSecret"
|
||||
value={values.consumerSecret}
|
||||
floatingLabel={intl.formatMessage(messages.consumerSecret)}
|
||||
className="pb-1"
|
||||
type="password"
|
||||
/>
|
||||
<FormikControl
|
||||
name="launchUrl"
|
||||
value={values.launchUrl}
|
||||
floatingLabel={intl.formatMessage(messages.launchUrl)}
|
||||
className="pb-1"
|
||||
type="input"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
LiveCommonFields.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
values: PropTypes.shape({
|
||||
consumerKey: PropTypes.string,
|
||||
consumerSecret: PropTypes.string,
|
||||
launchUrl: PropTypes.string,
|
||||
launchEmail: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LiveCommonFields);
|
||||
@@ -1,25 +1,29 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { camelCase } from 'lodash';
|
||||
import { SelectableBox, Icon } from '@edx/paragon';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { SelectableBox } from '@edx/frontend-lib-content-components';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as Yup from 'yup';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
import Loading from 'CourseAuthoring/generic/Loading';
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
|
||||
import { fetchLiveData, saveLiveConfiguration, saveLiveConfigurationAsDraft } from './data/thunks';
|
||||
import { selectApp } from './data/slice';
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import Loading from '../../generic/Loading';
|
||||
import { iconsSrc, bbbPlanTypes } from './constants';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import messages from './messages';
|
||||
import ZoomSettings from './ZoomSettings';
|
||||
import BBBSettings from './BBBSettings';
|
||||
|
||||
function LiveSettings({
|
||||
const LiveSettings = ({
|
||||
intl,
|
||||
onClose,
|
||||
}) {
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const courseId = useSelector(state => state.courseDetail.courseId);
|
||||
const availableProviders = useSelector((state) => state.live.appIds);
|
||||
@@ -57,10 +61,7 @@ function LiveSettings({
|
||||
is: (provider, tier) => provider === 'zoom' || (provider === 'big_blue_button' && tier === bbbPlanTypes.commercial),
|
||||
then: Yup.string().required(intl.formatMessage(messages.launchUrlRequired)),
|
||||
}),
|
||||
launchEmail: Yup.string().when('provider', {
|
||||
is: 'zoom',
|
||||
then: Yup.string().required(intl.formatMessage(messages.launchEmailRequired)),
|
||||
}),
|
||||
launchEmail: Yup.string(),
|
||||
};
|
||||
|
||||
const handleProviderChange = (providerId, setFieldValue, values) => {
|
||||
@@ -70,7 +71,7 @@ function LiveSettings({
|
||||
};
|
||||
|
||||
const handleSettingsSave = async (values) => {
|
||||
await dispatch(saveLiveConfiguration(courseId, values));
|
||||
await dispatch(saveLiveConfiguration(courseId, values, navigate));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,59 +79,55 @@ function LiveSettings({
|
||||
}, [courseId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppSettingsModal
|
||||
appId="live"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableLiveHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableLiveLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableLiveLink)}
|
||||
onClose={onClose}
|
||||
initialValues={liveConfiguration}
|
||||
validationSchema={validationSchema}
|
||||
onSettingsSave={handleSettingsSave}
|
||||
configureBeforeEnable
|
||||
enableReinitialize
|
||||
>
|
||||
{({ values, setFieldValue }) => (
|
||||
<AppSettingsModal
|
||||
appId="live"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableLiveHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableLiveLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableLiveLink)}
|
||||
onClose={onClose}
|
||||
initialValues={liveConfiguration}
|
||||
validationSchema={validationSchema}
|
||||
onSettingsSave={handleSettingsSave}
|
||||
configureBeforeEnable
|
||||
enableReinitialize
|
||||
>
|
||||
{({ values, setFieldValue }) => (
|
||||
(status === RequestStatus.IN_PROGRESS) ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
{(status === RequestStatus.IN_PROGRESS) ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<h4 className="my-3">{intl.formatMessage(messages.selectProvider)}</h4>
|
||||
<SelectableBox.Set
|
||||
type="checkbox"
|
||||
value={values.provider}
|
||||
onChange={(event) => handleProviderChange(event.target.value, setFieldValue, values)}
|
||||
name="provider"
|
||||
columns={3}
|
||||
className="mb-3"
|
||||
>
|
||||
{availableProviders.map((provider) => (
|
||||
<SelectableBox value={provider} type="checkbox" key={provider}>
|
||||
<div className="d-flex flex-column align-items-center">
|
||||
<Icon src={iconsSrc[`${camelCase(provider)}`]} alt={provider} />
|
||||
<span>{intl.formatMessage(messages[`appName-${camelCase(provider)}`])}</span>
|
||||
</div>
|
||||
</SelectableBox>
|
||||
))}
|
||||
</SelectableBox.Set>
|
||||
{values.provider === 'zoom' ? <ZoomSettings values={values} />
|
||||
: (
|
||||
<BBBSettings
|
||||
values={values}
|
||||
setFieldValue={setFieldValue}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<h4 className="my-3">{intl.formatMessage(messages.selectProvider)}</h4>
|
||||
<SelectableBox.Set
|
||||
type="checkbox"
|
||||
value={values.provider}
|
||||
onChange={(event) => handleProviderChange(event.target.value, setFieldValue, values)}
|
||||
name="provider"
|
||||
columns={3}
|
||||
className="mb-3"
|
||||
>
|
||||
{availableProviders.map((provider) => (
|
||||
<SelectableBox value={provider} type="checkbox" key={provider}>
|
||||
<div className="d-flex flex-column align-items-center">
|
||||
<Icon src={iconsSrc[`${camelCase(provider)}`]} alt={provider} />
|
||||
<span>{intl.formatMessage(messages[`appName-${camelCase(provider)}`])}</span>
|
||||
</div>
|
||||
</SelectableBox>
|
||||
))}
|
||||
</SelectableBox.Set>
|
||||
{values.provider === 'zoom' ? <ZoomSettings values={values} />
|
||||
: (
|
||||
<BBBSettings
|
||||
values={values}
|
||||
setFieldValue={setFieldValue}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AppSettingsModal>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</AppSettingsModal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
LiveSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
@@ -11,15 +11,17 @@ import {
|
||||
} from '@testing-library/react';
|
||||
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Switch } from 'react-router-dom';
|
||||
import { initializeMockApp, history } from '@edx/frontend-platform';
|
||||
import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider, PageRoute } from '@edx/frontend-platform/react';
|
||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import { executeThunk } from '../../utils';
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
|
||||
import LiveSettings from './Settings';
|
||||
import {
|
||||
generateLiveConfigurationApiResponse,
|
||||
@@ -31,7 +33,6 @@ import {
|
||||
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
|
||||
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
|
||||
import messages from './messages';
|
||||
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
|
||||
|
||||
let axiosMock;
|
||||
let container;
|
||||
@@ -44,13 +45,13 @@ ReactDOM.createPortal = jest.fn(node => node);
|
||||
const renderComponent = () => {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<PagesAndResourcesProvider courseId={courseId}>
|
||||
<Switch>
|
||||
<PageRoute path={liveSettingsUrl}>
|
||||
<LiveSettings onClose={() => {}} />
|
||||
</PageRoute>
|
||||
</Switch>
|
||||
<MemoryRouter initialEntries={[liveSettingsUrl]}>
|
||||
<Routes>
|
||||
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PagesAndResourcesProvider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
@@ -59,10 +60,10 @@ const renderComponent = () => {
|
||||
};
|
||||
|
||||
const mockStore = async ({
|
||||
usernameSharing = false,
|
||||
emailSharing = false,
|
||||
enabled = true,
|
||||
piiSharingAllowed = true,
|
||||
usernameSharing = false,
|
||||
emailSharing = false,
|
||||
enabled = true,
|
||||
piiSharingAllowed = true,
|
||||
}) => {
|
||||
const fetchProviderConfigUrl = `${providersApiUrl}/${courseId}/`;
|
||||
const fetchLiveConfigUrl = `${providerConfigurationApiUrl}/${courseId}/`;
|
||||
@@ -86,7 +87,6 @@ describe('LiveSettings', () => {
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
history.push(liveSettingsUrl);
|
||||
});
|
||||
|
||||
test('Live Configuration modal is visible', async () => {
|
||||
@@ -1,43 +1,43 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||
|
||||
import messages from './messages';
|
||||
import { providerNames } from './constants';
|
||||
import LiveCommonFields from './LiveCommonFields';
|
||||
import FormikControl from '../../generic/FormikControl';
|
||||
|
||||
function ZoomsSettings({
|
||||
intl,
|
||||
values,
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{(!values.piiSharingEnable && (values.piiSharingEmail || values.piiSharingUsername)) ? (
|
||||
<p data-testid="request-pii-sharing">
|
||||
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{(values.piiSharingEmail || values.piiSharingUsername)
|
||||
const ZoomSettings = ({
|
||||
intl,
|
||||
values,
|
||||
}) => (
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
<>
|
||||
{!values.piiSharingEnable ? (
|
||||
<p data-testid="request-pii-sharing">
|
||||
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{(values.piiSharingEmail || values.piiSharingUsername)
|
||||
&& (
|
||||
<p data-testid="helper-text">
|
||||
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
|
||||
</p>
|
||||
)}
|
||||
<LiveCommonFields values={values} />
|
||||
<FormikControl
|
||||
name="launchEmail"
|
||||
value={values.launchEmail}
|
||||
floatingLabel={intl.formatMessage(messages.launchEmail)}
|
||||
type="input"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
<LiveCommonFields values={values} />
|
||||
<FormikControl
|
||||
name="launchEmail"
|
||||
value={values.launchEmail}
|
||||
floatingLabel={intl.formatMessage(messages.launchEmail)}
|
||||
type="input"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
ZoomsSettings.propTypes = {
|
||||
ZoomSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
values: PropTypes.shape({
|
||||
consumerKey: PropTypes.string,
|
||||
@@ -51,4 +51,4 @@ ZoomsSettings.propTypes = {
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ZoomsSettings);
|
||||
export default injectIntl(ZoomSettings);
|
||||
@@ -6,15 +6,16 @@ import {
|
||||
} from '@testing-library/react';
|
||||
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Switch } from 'react-router-dom';
|
||||
import { initializeMockApp, history } from '@edx/frontend-platform';
|
||||
import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider, PageRoute } from '@edx/frontend-platform/react';
|
||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import { executeThunk } from '../../utils';
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import LiveSettings from './Settings';
|
||||
import {
|
||||
generateLiveConfigurationApiResponse,
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
|
||||
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
|
||||
import messages from './messages';
|
||||
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
|
||||
|
||||
let axiosMock;
|
||||
let container;
|
||||
@@ -39,13 +39,13 @@ ReactDOM.createPortal = jest.fn(node => node);
|
||||
const renderComponent = () => {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<PagesAndResourcesProvider courseId={courseId}>
|
||||
<Switch>
|
||||
<PageRoute path={liveSettingsUrl}>
|
||||
<LiveSettings onClose={() => {}} />
|
||||
</PageRoute>
|
||||
</Switch>
|
||||
<MemoryRouter initialEntries={[liveSettingsUrl]}>
|
||||
<Routes>
|
||||
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PagesAndResourcesProvider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
@@ -54,10 +54,10 @@ const renderComponent = () => {
|
||||
};
|
||||
|
||||
const mockStore = async ({
|
||||
usernameSharing = false,
|
||||
emailSharing = false,
|
||||
enabled = true,
|
||||
piiSharingAllowed = true,
|
||||
usernameSharing = false,
|
||||
emailSharing = false,
|
||||
enabled = true,
|
||||
piiSharingAllowed = true,
|
||||
}) => {
|
||||
const fetchProviderConfigUrl = `${providersApiUrl}/${courseId}/`;
|
||||
const fetchLiveConfigUrl = `${providerConfigurationApiUrl}/${courseId}/`;
|
||||
@@ -81,11 +81,10 @@ describe('Zoom Settings', () => {
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
history.push(liveSettingsUrl);
|
||||
});
|
||||
|
||||
test('LTI fields are visible when pii sharing is enabled and email or username sharing required', async () => {
|
||||
await mockStore({ emailSharing: true });
|
||||
test('LTI fields are visible when pii sharing is enabled', async () => {
|
||||
await mockStore({ piiSharingAllowed: true });
|
||||
renderComponent();
|
||||
|
||||
const spinner = getByRole(container, 'status');
|
||||
@@ -107,9 +106,9 @@ describe('Zoom Settings', () => {
|
||||
});
|
||||
|
||||
test(
|
||||
'Only connect to support message is visible when pii sharing is disabled and email or username sharing is required',
|
||||
'Only connect to support message is visible when pii sharing is disabled',
|
||||
async () => {
|
||||
await mockStore({ emailSharing: true, piiSharingAllowed: false });
|
||||
await mockStore({ piiSharingAllowed: false });
|
||||
renderComponent();
|
||||
|
||||
const spinner = getByRole(container, 'status');
|
||||
@@ -133,7 +132,7 @@ describe('Zoom Settings', () => {
|
||||
|
||||
test('Provider Configuration should be displayed correctly', async () => {
|
||||
const apiDefaultResponse = generateLiveConfigurationApiResponse(true, true);
|
||||
await mockStore({ emailSharing: false, piiSharingAllowed: false });
|
||||
await mockStore({ piiSharingAllowed: true });
|
||||
renderComponent();
|
||||
|
||||
const spinner = getByRole(container, 'status');
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
GoogleMeet, MicrosoftTeams, Zoom, Bbb,
|
||||
} from '@edx/paragon/icons';
|
||||
GoogleMeet, MicrosoftTeams, Zoom, Bbb,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
export const iconsSrc = {
|
||||
googleMeet: GoogleMeet,
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'live',
|
||||
@@ -1,14 +1,14 @@
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { addModel, addModels, updateModel } from '../../../generic/model-store';
|
||||
import { addModel, addModels, updateModel } from 'CourseAuthoring/generic/model-store';
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
|
||||
import {
|
||||
getLiveConfiguration,
|
||||
getLiveProviders,
|
||||
postLiveConfiguration,
|
||||
normalizeSettings,
|
||||
deNormalizeSettings,
|
||||
getLiveConfiguration,
|
||||
getLiveProviders,
|
||||
postLiveConfiguration,
|
||||
normalizeSettings,
|
||||
deNormalizeSettings,
|
||||
} from './api';
|
||||
import { loadApps, updateStatus, updateSaveStatus } from './slice';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
|
||||
function updateLiveSettingsState({
|
||||
appConfig,
|
||||
@@ -56,7 +56,7 @@ export function fetchLiveData(courseId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function saveLiveConfiguration(courseId, config) {
|
||||
export function saveLiveConfiguration(courseId, config, navigate) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSaveStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
try {
|
||||
@@ -64,7 +64,7 @@ export function saveLiveConfiguration(courseId, config) {
|
||||
dispatch(updateLiveSettingsState(apps));
|
||||
|
||||
dispatch(updateSaveStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
history.push(`/course/${courseId}/pages-and-resources/`);
|
||||
navigate(`/course/${courseId}/pages-and-resources/`);
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 403) {
|
||||
dispatch(updateSaveStatus({ status: RequestStatus.DENIED }));
|
||||
@@ -36,11 +36,11 @@ export const initialState = {
|
||||
export const configurationProviders = (
|
||||
emailSharing,
|
||||
usernameSharing,
|
||||
activeProvider = 'zoom',
|
||||
activeProvider,
|
||||
hasFreeTier,
|
||||
) => ({
|
||||
providers: {
|
||||
active: activeProvider,
|
||||
active: activeProvider || 'zoom',
|
||||
available: {
|
||||
zoom: {
|
||||
features: [],
|
||||
@@ -65,7 +65,7 @@ export const generateLiveConfigurationApiResponse = (
|
||||
enabled,
|
||||
piiSharingAllowed,
|
||||
providerType = 'zoom',
|
||||
isFreeTier,
|
||||
isFreeTier = undefined,
|
||||
) => ({
|
||||
course_key: courseId,
|
||||
enabled,
|
||||
@@ -160,9 +160,14 @@ const messages = defineMessages({
|
||||
|
||||
freePlanMessage: {
|
||||
id: 'authoring.live.freePlanMessage',
|
||||
defaultMessage: 'The free plan is pre-configured, and no additional configurations are required.',
|
||||
defaultMessage: 'The free plan is pre-configured, and no additional configurations are required. By selecting the free plan, you are agreeing to Blindside Networks',
|
||||
description: 'Tells user that free plans requires no additional configurations',
|
||||
},
|
||||
privacyPolicy: {
|
||||
id: 'authoring.live.privacyPolicy',
|
||||
defaultMessage: 'Privacy Policy.',
|
||||
description: 'The text of privacy policy hyperlink for free plan',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
23
plugins/course-apps/live/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-live",
|
||||
"version": "0.1.0",
|
||||
"description": "Live course configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-lib-content-components": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"@reduxjs/toolkit": "*",
|
||||
"lodash": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"react-redux": "*",
|
||||
"react-router-dom": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
69
plugins/course-apps/ora_settings/Settings.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Hyperlink } from '@openedx/paragon';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
|
||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||
import { useAppSetting } from 'CourseAuthoring/utils';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
const ORASettings = ({ intl, onClose }) => {
|
||||
const appId = 'ora_settings';
|
||||
const appInfo = useModel('courseApps', appId);
|
||||
const [enableFlexiblePeerGrade, saveSetting] = useAppSetting(
|
||||
'forceOnFlexiblePeerOpenassessments',
|
||||
);
|
||||
const handleSettingsSave = (values) => saveSetting(values.enableFlexiblePeerGrade);
|
||||
|
||||
const title = (
|
||||
<div>
|
||||
<p>{intl.formatMessage(messages.heading)}</p>
|
||||
<div className="pt-3">
|
||||
<Hyperlink
|
||||
className="text-primary-500 small"
|
||||
destination={appInfo.documentationLinks?.learnMoreConfiguration}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{intl.formatMessage(messages.ORASettingsHelpLink)}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId={appId}
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
initialValues={{ enableFlexiblePeerGrade }}
|
||||
validationSchema={{ enableFlexiblePeerGrade: Yup.boolean() }}
|
||||
onSettingsSave={handleSettingsSave}
|
||||
hideAppToggle
|
||||
>
|
||||
{({ values, handleChange, handleBlur }) => (
|
||||
<FormSwitchGroup
|
||||
id="enable-flexible-peer-grade"
|
||||
name="enableFlexiblePeerGrade"
|
||||
label={intl.formatMessage(messages.enableFlexPeerGradeLabel)}
|
||||
helpText={intl.formatMessage(messages.enableFlexPeerGradeHelp)}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
checked={values.enableFlexiblePeerGrade}
|
||||
/>
|
||||
)}
|
||||
</AppSettingsModal>
|
||||
);
|
||||
};
|
||||
|
||||
ORASettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ORASettings);
|
||||
33
plugins/course-apps/ora_settings/Settings.test.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
import ORASettings from './Settings';
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform/i18n'), // use actual for all non-hook parts
|
||||
injectIntl: (component) => component,
|
||||
intlShape: {},
|
||||
}));
|
||||
jest.mock('yup', () => ({
|
||||
boolean: jest.fn().mockReturnValue('Yub.boolean'),
|
||||
}));
|
||||
jest.mock('CourseAuthoring/generic/model-store', () => ({
|
||||
useModel: jest.fn().mockReturnValue({ documentationLinks: { learnMoreConfiguration: 'https://learnmore.test' } }),
|
||||
}));
|
||||
jest.mock('CourseAuthoring/generic/FormSwitchGroup', () => 'FormSwitchGroup');
|
||||
jest.mock('CourseAuthoring/utils', () => ({
|
||||
useAppSetting: jest.fn().mockReturnValue(['abitrary value', jest.fn().mockName('saveSetting')]),
|
||||
}));
|
||||
jest.mock('CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal', () => 'AppSettingsModal');
|
||||
|
||||
const props = {
|
||||
onClose: jest.fn().mockName('onClose'),
|
||||
intl: {
|
||||
formatMessage: (message) => message.defaultMessage,
|
||||
},
|
||||
};
|
||||
|
||||
describe('ORASettings', () => {
|
||||
it('should render', () => {
|
||||
const wrapper = shallow(<ORASettings {...props} />);
|
||||
expect(wrapper.snapshot).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ORASettings should render 1`] = `
|
||||
<AppSettingsModal
|
||||
appId="ora_settings"
|
||||
hideAppToggle={true}
|
||||
initialValues={
|
||||
Object {
|
||||
"enableFlexiblePeerGrade": "abitrary value",
|
||||
}
|
||||
}
|
||||
onClose={[MockFunction onClose]}
|
||||
onSettingsSave={[Function]}
|
||||
title={
|
||||
<div>
|
||||
<p>
|
||||
Configure open response assessment
|
||||
</p>
|
||||
<div
|
||||
className="pt-3"
|
||||
>
|
||||
<withDeprecatedProps(Hyperlink)
|
||||
className="text-primary-500 small"
|
||||
destination="https://learnmore.test"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about open response assessment settings
|
||||
</withDeprecatedProps(Hyperlink)>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
validationSchema={
|
||||
Object {
|
||||
"enableFlexiblePeerGrade": "Yub.boolean",
|
||||
}
|
||||
}
|
||||
>
|
||||
[Function]
|
||||
</AppSettingsModal>
|
||||
`;
|
||||
22
plugins/course-apps/ora_settings/messages.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.pages-resources.ora.heading',
|
||||
defaultMessage: 'Configure open response assessment',
|
||||
},
|
||||
ORASettingsHelpLink: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.link',
|
||||
defaultMessage: 'Learn more about open response assessment settings',
|
||||
},
|
||||
enableFlexPeerGradeLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.label',
|
||||
defaultMessage: 'Flex Peer Grading',
|
||||
},
|
||||
enableFlexPeerGradeHelp: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.help',
|
||||
defaultMessage: 'Turn on Flexible Peer Grading for all open response assessments in the course with peer grading.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
19
plugins/course-apps/ora_settings/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-ora_settings",
|
||||
"version": "0.1.0",
|
||||
"description": "Open Response Assessment configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,23 +11,25 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
|
||||
} from '@edx/paragon';
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
|
||||
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
|
||||
import Loading from 'CourseAuthoring/generic/Loading';
|
||||
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
|
||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
||||
import { useIsMobile } from 'CourseAuthoring/utils';
|
||||
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
|
||||
import StudioApiService from '../../data/services/StudioApiService';
|
||||
import Loading from '../../generic/Loading';
|
||||
import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert';
|
||||
import FormSwitchGroup from '../../generic/FormSwitchGroup';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert';
|
||||
import { useIsMobile } from '../../utils';
|
||||
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
|
||||
import messages from './messages';
|
||||
|
||||
function ProctoringSettings({ intl, onClose }) {
|
||||
const ProctoringSettings = ({ intl, onClose }) => {
|
||||
const initialFormValues = {
|
||||
enableProctoredExams: false,
|
||||
proctoringProvider: false,
|
||||
proctortrackEscalationEmail: '',
|
||||
escalationEmail: '',
|
||||
allowOptingOut: false,
|
||||
createZendeskTickets: false,
|
||||
};
|
||||
@@ -36,12 +38,14 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [loadingConnectionError, setLoadingConnectionError] = useState(false);
|
||||
const [loadingPermissionError, setLoadingPermissionError] = useState(false);
|
||||
const [allowLtiProviders, setAllowLtiProviders] = useState(false);
|
||||
const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]);
|
||||
const [ltiProctoringProviders, setLtiProctoringProviders] = useState([]);
|
||||
const [courseStartDate, setCourseStartDate] = useState('');
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
const [submissionInProgress, setSubmissionInProgress] = useState(false);
|
||||
const [showProctortrackEscalationEmail, setShowProctortrackEscalationEmail] = useState(false);
|
||||
const [showEscalationEmail, setShowEscalationEmail] = useState(false);
|
||||
const isEdxStaff = getAuthenticatedUser().administrator;
|
||||
const [formStatus, setFormStatus] = useState({
|
||||
isValid: true,
|
||||
@@ -50,6 +54,15 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
const isMobile = useIsMobile();
|
||||
const modalVariant = isMobile ? 'dark' : 'default';
|
||||
|
||||
const isLtiProvider = (provider) => (
|
||||
ltiProctoringProviders.some(p => p.name === provider)
|
||||
);
|
||||
|
||||
function getProviderDisplayLabel(provider) {
|
||||
// if a display label exists for this provider return it
|
||||
return ltiProctoringProviders.find(p => p.name === provider)?.verbose_name || provider;
|
||||
}
|
||||
|
||||
const { courseId } = useContext(PagesAndResourcesContext);
|
||||
const appInfo = useModel('courseApps', 'proctoring');
|
||||
const alertRef = React.createRef();
|
||||
@@ -57,7 +70,7 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
const proctoringEscalationEmailInputRef = useRef(null);
|
||||
const submitButtonState = submissionInProgress ? 'pending' : 'default';
|
||||
|
||||
function handleChange(event) {
|
||||
const handleChange = (event) => {
|
||||
const { target } = event;
|
||||
const value = target.type === 'checkbox' ? target.checked : target.value;
|
||||
const { name } = target;
|
||||
@@ -70,70 +83,96 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
|
||||
if (value === 'proctortrack') {
|
||||
setFormValues({ ...newFormValues, createZendeskTickets: false });
|
||||
setShowProctortrackEscalationEmail(true);
|
||||
setShowEscalationEmail(true);
|
||||
} else if (value === 'software_secure') {
|
||||
setFormValues({ ...newFormValues, createZendeskTickets: true });
|
||||
setShowEscalationEmail(false);
|
||||
} else if (isLtiProvider(value)) {
|
||||
setFormValues(newFormValues);
|
||||
setShowEscalationEmail(true);
|
||||
} else {
|
||||
if (value === 'software_secure') {
|
||||
setFormValues({ ...newFormValues, createZendeskTickets: true });
|
||||
} else {
|
||||
setFormValues(newFormValues);
|
||||
}
|
||||
|
||||
setShowProctortrackEscalationEmail(false);
|
||||
setFormValues(newFormValues);
|
||||
setShowEscalationEmail(false);
|
||||
}
|
||||
} else {
|
||||
setFormValues({ ...formValues, [name]: value });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function setFocusToProctortrackEscalationEmailInput() {
|
||||
const setFocusToEscalationEmailInput = () => {
|
||||
if (proctoringEscalationEmailInputRef && proctoringEscalationEmailInputRef.current) {
|
||||
proctoringEscalationEmailInputRef.current.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function postSettingsBackToServer() {
|
||||
const dataToPostBack = {
|
||||
const selectedProvider = formValues.proctoringProvider;
|
||||
const isLtiProviderSelected = isLtiProvider(selectedProvider);
|
||||
const studioDataToPostBack = {
|
||||
proctored_exam_settings: {
|
||||
enable_proctored_exams: formValues.enableProctoredExams,
|
||||
proctoring_provider: formValues.proctoringProvider,
|
||||
// lti providers are managed outside edx-platform, lti_external indicates this
|
||||
proctoring_provider: isLtiProviderSelected ? 'lti_external' : selectedProvider,
|
||||
create_zendesk_tickets: formValues.createZendeskTickets,
|
||||
},
|
||||
};
|
||||
if (isEdxStaff) {
|
||||
dataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut;
|
||||
studioDataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut;
|
||||
}
|
||||
|
||||
if (formValues.proctoringProvider === 'proctortrack') {
|
||||
dataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.proctortrackEscalationEmail === '' ? null : formValues.proctortrackEscalationEmail;
|
||||
studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.escalationEmail === '' ? null : formValues.escalationEmail;
|
||||
}
|
||||
|
||||
// only save back to exam service if necessary
|
||||
setSubmissionInProgress(true);
|
||||
StudioApiService.saveProctoredExamSettingsData(courseId, dataToPostBack).then(() => {
|
||||
setSaveSuccess(true);
|
||||
setSaveError(false);
|
||||
setSubmissionInProgress(false);
|
||||
}).catch(() => {
|
||||
setSaveSuccess(false);
|
||||
setSaveError(true);
|
||||
setSubmissionInProgress(false);
|
||||
});
|
||||
|
||||
const saveOperations = [StudioApiService.saveProctoredExamSettingsData(courseId, studioDataToPostBack)];
|
||||
if (allowLtiProviders && ExamsApiService.isAvailable()) {
|
||||
const selectedEscalationEmail = formValues.escalationEmail;
|
||||
|
||||
saveOperations.push(
|
||||
ExamsApiService.saveCourseExamConfiguration(
|
||||
courseId,
|
||||
{
|
||||
provider: isLtiProviderSelected ? formValues.proctoringProvider : null,
|
||||
escalationEmail: (isLtiProviderSelected && selectedEscalationEmail !== '') ? selectedEscalationEmail : null,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
Promise.all(saveOperations)
|
||||
.then(() => {
|
||||
setSaveSuccess(true);
|
||||
setSaveError(false);
|
||||
setSubmissionInProgress(false);
|
||||
}).catch(() => {
|
||||
setSaveSuccess(false);
|
||||
setSaveError(true);
|
||||
setSubmissionInProgress(false);
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit(event) {
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider);
|
||||
if (
|
||||
formValues.proctoringProvider === 'proctortrack'
|
||||
&& !EmailValidator.validate(formValues.proctortrackEscalationEmail)
|
||||
&& !(formValues.proctortrackEscalationEmail === '' && !formValues.enableProctoredExams)
|
||||
(formValues.proctoringProvider === 'proctortrack' || isLtiProviderSelected)
|
||||
&& !EmailValidator.validate(formValues.escalationEmail)
|
||||
&& !(formValues.escalationEmail === '' && !formValues.enableProctoredExams)
|
||||
) {
|
||||
if (formValues.proctortrackEscalationEmail === '') {
|
||||
const errorMessage = intl.formatMessage(messages['authoring.proctoring.escalationemail.error.blank']);
|
||||
if (formValues.escalationEmail === '') {
|
||||
const errorMessage = intl.formatMessage(messages['authoring.proctoring.escalationemail.error.blank'], { proctoringProviderName: getProviderDisplayLabel(formValues.proctoringProvider) });
|
||||
|
||||
setFormStatus({
|
||||
isValid: false,
|
||||
errors: {
|
||||
formProctortrackEscalationEmail: {
|
||||
dialogErrorMessage: (<Alert.Link onClick={setFocusToProctortrackEscalationEmailInput} href="#formProctortrackEscalationEmail" data-testid="proctorTrackEscalationEmailErrorLink">{errorMessage}</Alert.Link>),
|
||||
formEscalationEmail: {
|
||||
dialogErrorMessage: (
|
||||
<Alert.Link onClick={setFocusToEscalationEmailInput} href="#formEscalationEmail" data-testid="escalationEmailErrorLink">
|
||||
{errorMessage}
|
||||
</Alert.Link>
|
||||
),
|
||||
inputErrorMessage: errorMessage,
|
||||
},
|
||||
},
|
||||
@@ -144,8 +183,8 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
setFormStatus({
|
||||
isValid: false,
|
||||
errors: {
|
||||
formProctortrackEscalationEmail: {
|
||||
dialogErrorMessage: (<Alert.Link onClick={setFocusToProctortrackEscalationEmailInput} href="#formProctortrackEscalationEmail" data-testid="proctorTrackEscalationEmailErrorLink">{errorMessage}</Alert.Link>),
|
||||
formEscalationEmail: {
|
||||
dialogErrorMessage: (<Alert.Link onClick={setFocusToEscalationEmailInput} href="#formEscalationEmail" data-testid="escalationEmailErrorLink">{errorMessage}</Alert.Link>),
|
||||
inputErrorMessage: errorMessage,
|
||||
},
|
||||
},
|
||||
@@ -154,13 +193,13 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
} else {
|
||||
postSettingsBackToServer();
|
||||
const errors = { ...formStatus.errors };
|
||||
delete errors.formProctortrackEscalationEmail;
|
||||
delete errors.formEscalationEmail;
|
||||
setFormStatus({
|
||||
isValid: true,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function cannotEditProctoringProvider() {
|
||||
const currentDate = moment(moment()).format('YYYY-MM-DD[T]hh:mm:ss[Z]');
|
||||
@@ -186,7 +225,7 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
disabled={isDisabledOption(provider)}
|
||||
data-testid={provider}
|
||||
>
|
||||
{provider}
|
||||
{getProviderDisplayLabel(provider)}
|
||||
</option>
|
||||
));
|
||||
}
|
||||
@@ -206,7 +245,7 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
const learnMoreLink = appInfo.documentationLinks?.learnMoreConfiguration && (
|
||||
const learnMoreLink = appInfo?.documentationLinks?.learnMoreConfiguration && (
|
||||
<Hyperlink
|
||||
className="text-primary-500"
|
||||
destination={appInfo.documentationLinks.learnMoreConfiguration}
|
||||
@@ -218,16 +257,18 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
);
|
||||
|
||||
function renderContent() {
|
||||
const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!formStatus.isValid && formStatus.errors.formProctortrackEscalationEmail
|
||||
{!formStatus.isValid && formStatus.errors.formEscalationEmail
|
||||
&& (
|
||||
// tabIndex="-1" to make non-focusable element focusable
|
||||
<Alert
|
||||
id="proctortrackEscalationEmailError"
|
||||
id="escalationEmailError"
|
||||
variant="danger"
|
||||
tabIndex="-1"
|
||||
data-testid="proctortrackEscalationEmailError"
|
||||
data-testid="escalationEmailError"
|
||||
ref={alertRef}
|
||||
>
|
||||
{getFormErrorMessage()}
|
||||
@@ -290,30 +331,30 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* PROCTORTRACK ESCALATION EMAIL */}
|
||||
{showProctortrackEscalationEmail && formValues.enableProctoredExams && (
|
||||
<Form.Group controlId="formProctortrackEscalationEmail">
|
||||
{/* ESCALATION EMAIL */}
|
||||
{showEscalationEmail && formValues.enableProctoredExams && (
|
||||
<Form.Group controlId="formEscalationEmail">
|
||||
<Form.Label className="font-weight-bold">
|
||||
{intl.formatMessage(messages['authoring.proctoring.escalationemail.label'])}
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
ref={proctoringEscalationEmailInputRef}
|
||||
type="email"
|
||||
name="proctortrackEscalationEmail"
|
||||
name="escalationEmail"
|
||||
data-testid="escalationEmail"
|
||||
onChange={handleChange}
|
||||
value={formValues.proctortrackEscalationEmail}
|
||||
isInvalid={Object.prototype.hasOwnProperty.call(formStatus.errors, 'formProctortrackEscalationEmail')}
|
||||
aria-describedby="proctortrackEscalationEmailHelpText"
|
||||
value={formValues.escalationEmail}
|
||||
isInvalid={Object.prototype.hasOwnProperty.call(formStatus.errors, 'formEscalationEmail')}
|
||||
aria-describedby="escalationEmailHelpText"
|
||||
/>
|
||||
<Form.Text id="proctortrackEscalationEmailHelpText">
|
||||
<Form.Text id="escalationEmailHelpText">
|
||||
{intl.formatMessage(messages['authoring.proctoring.escalationemail.help'])}
|
||||
</Form.Text>
|
||||
{Object.prototype.hasOwnProperty.call(formStatus.errors, 'formProctortrackEscalationEmail') && (
|
||||
{Object.prototype.hasOwnProperty.call(formStatus.errors, 'formEscalationEmail') && (
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{
|
||||
formStatus.errors.formProctortrackEscalationEmail
|
||||
&& formStatus.errors.formProctortrackEscalationEmail.inputErrorMessage
|
||||
formStatus.errors.formEscalationEmail
|
||||
&& formStatus.errors.formEscalationEmail.inputErrorMessage
|
||||
}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
@@ -321,7 +362,7 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
)}
|
||||
|
||||
{/* ALLOW OPTING OUT OF PROCTORED EXAMS */}
|
||||
{ isEdxStaff && formValues.enableProctoredExams && (
|
||||
{ isEdxStaff && formValues.enableProctoredExams && !isLtiProviderSelected && (
|
||||
<fieldset aria-describedby="allowOptingOutHelpText">
|
||||
<Form.Group controlId="formAllowingOptingOut">
|
||||
<Form.Label as="legend" className="font-weight-bold">
|
||||
@@ -329,6 +370,7 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
</Form.Label>
|
||||
<Form.RadioSet
|
||||
name="allowOptingOut"
|
||||
data-testid="allowOptingOutRadio"
|
||||
value={formValues.allowOptingOut.toString()}
|
||||
onChange={handleChange}
|
||||
>
|
||||
@@ -344,7 +386,7 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
)}
|
||||
|
||||
{/* CREATE ZENDESK TICKETS */}
|
||||
{ isEdxStaff && formValues.enableProctoredExams && (
|
||||
{ isEdxStaff && formValues.enableProctoredExams && !isLtiProviderSelected && (
|
||||
<fieldset aria-describedby="createZendeskTicketsText">
|
||||
<Form.Group controlId="formCreateZendeskTickets">
|
||||
<Form.Label as="legend" className="font-weight-bold">
|
||||
@@ -444,47 +486,83 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
StudioApiService.getProctoredExamSettingsData(courseId)
|
||||
.then(
|
||||
response => {
|
||||
const proctoredExamSettings = response.data.proctored_exam_settings;
|
||||
setLoaded(true);
|
||||
setLoading(false);
|
||||
setSubmissionInProgress(false);
|
||||
setCourseStartDate(response.data.course_start_date);
|
||||
const isProctortrack = proctoredExamSettings.proctoring_provider === 'proctortrack';
|
||||
setShowProctortrackEscalationEmail(isProctortrack);
|
||||
setAvailableProctoringProviders(response.data.available_proctoring_providers);
|
||||
const proctoringEscalationEmail = proctoredExamSettings.proctoring_escalation_email;
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
StudioApiService.getProctoredExamSettingsData(courseId),
|
||||
ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(),
|
||||
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders() : Promise.resolve(),
|
||||
])
|
||||
.then(
|
||||
([settingsResponse, examConfigResponse, ltiProvidersResponse]) => {
|
||||
const proctoredExamSettings = settingsResponse.data.proctored_exam_settings;
|
||||
setLoaded(true);
|
||||
setLoading(false);
|
||||
setSubmissionInProgress(false);
|
||||
setCourseStartDate(settingsResponse.data.course_start_date);
|
||||
setAvailableProctoringProviders(settingsResponse.data.available_proctoring_providers);
|
||||
|
||||
setFormValues({
|
||||
...formValues,
|
||||
enableProctoredExams: proctoredExamSettings.enable_proctored_exams,
|
||||
proctoringProvider: proctoredExamSettings.proctoring_provider,
|
||||
allowOptingOut: proctoredExamSettings.allow_proctoring_opt_out,
|
||||
createZendeskTickets: proctoredExamSettings.create_zendesk_tickets,
|
||||
// The backend API may return null for the proctoringEscalationEmail value, which is the default.
|
||||
// In order to keep our email input component controlled, we use the empty string as the default
|
||||
// and perform this conversion during GETs and POSTs.
|
||||
proctortrackEscalationEmail: proctoringEscalationEmail === null ? '' : proctoringEscalationEmail,
|
||||
});
|
||||
},
|
||||
).catch(
|
||||
error => {
|
||||
if (error.response.status === 403) {
|
||||
setLoadingPermissionError(true);
|
||||
} else {
|
||||
setLoadingConnectionError(true);
|
||||
}
|
||||
setLoading(false);
|
||||
setLoaded(false);
|
||||
setSubmissionInProgress(false);
|
||||
},
|
||||
);
|
||||
}, [],
|
||||
);
|
||||
// The list of providers returned by studio settings are the default behavior. If lti_external
|
||||
// is available as an option display the list of LTI providers returned by the exam service.
|
||||
// Setting 'lti_external' in studio indicates an LTI provider configured outside of edx-platform.
|
||||
// This option is not directly selectable.
|
||||
const proctoringProvidersStudio = settingsResponse.data.available_proctoring_providers;
|
||||
const proctoringProvidersLti = ltiProvidersResponse?.data || [];
|
||||
const enableLtiProviders = proctoringProvidersStudio.includes('lti_external');
|
||||
setAllowLtiProviders(enableLtiProviders);
|
||||
setLtiProctoringProviders(proctoringProvidersLti);
|
||||
// flatten provider objects and coalesce values to just the provider key
|
||||
let availableProviders = proctoringProvidersStudio.filter(value => value !== 'lti_external');
|
||||
if (enableLtiProviders) {
|
||||
availableProviders = proctoringProvidersLti.reduce(
|
||||
(result, provider) => [...result, provider.name],
|
||||
availableProviders,
|
||||
);
|
||||
}
|
||||
setAvailableProctoringProviders(availableProviders);
|
||||
|
||||
let selectedProvider;
|
||||
if (proctoredExamSettings.proctoring_provider === 'lti_external') {
|
||||
selectedProvider = examConfigResponse.data.provider;
|
||||
} else {
|
||||
selectedProvider = proctoredExamSettings.proctoring_provider;
|
||||
}
|
||||
|
||||
const isProctortrack = selectedProvider === 'proctortrack';
|
||||
const ltiProviderSelected = proctoringProvidersLti.some(p => p.name === selectedProvider);
|
||||
|
||||
if (isProctortrack || ltiProviderSelected) {
|
||||
setShowEscalationEmail(true);
|
||||
}
|
||||
|
||||
const proctoringEscalationEmail = ltiProviderSelected
|
||||
? examConfigResponse.data.escalation_email
|
||||
: proctoredExamSettings.proctoring_escalation_email;
|
||||
|
||||
setFormValues({
|
||||
...formValues,
|
||||
proctoringProvider: selectedProvider,
|
||||
enableProctoredExams: proctoredExamSettings.enable_proctored_exams,
|
||||
allowOptingOut: proctoredExamSettings.allow_proctoring_opt_out,
|
||||
createZendeskTickets: proctoredExamSettings.create_zendesk_tickets,
|
||||
// The backend API may return null for the proctoringEscalationEmail value, which is the default.
|
||||
// In order to keep our email input component controlled, we use the empty string as the default
|
||||
// and perform this conversion during GETs and POSTs.
|
||||
escalationEmail: proctoringEscalationEmail === null ? '' : proctoringEscalationEmail,
|
||||
});
|
||||
},
|
||||
).catch(
|
||||
error => {
|
||||
if (error.response?.status === 403) {
|
||||
setLoadingPermissionError(true);
|
||||
} else {
|
||||
setLoadingConnectionError(true);
|
||||
}
|
||||
setLoading(false);
|
||||
setLoaded(false);
|
||||
setSubmissionInProgress(false);
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if ((saveSuccess || saveError) && !!saveStatusAlertRef.current) {
|
||||
@@ -545,7 +623,7 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
</Form>
|
||||
</ModalDialog>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ProctoringSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
@@ -1,34 +1,37 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
render, screen, cleanup, waitFor, waitForElementToBeRemoved, fireEvent, act,
|
||||
render, screen, cleanup, waitFor, fireEvent, act,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
// import * as auth from '@edx/frontend-platform/auth';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { initializeMockApp, mergeConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import ProctoredExamSettings from './ProctoredExamSettings';
|
||||
import StudioApiService from '../data/services/StudioApiService';
|
||||
import ExamsApiService from '../data/services/ExamsApiService';
|
||||
import initializeStore from '../store';
|
||||
|
||||
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
|
||||
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import ProctoredExamSettings from './Settings';
|
||||
|
||||
const defaultProps = {
|
||||
courseId: 'course-v1%3AedX%2BDemoX%2BDemo_Course',
|
||||
onClose: () => {},
|
||||
};
|
||||
|
||||
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const intlWrapper = children => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
{children}
|
||||
</IntlProvider>
|
||||
<PagesAndResourcesProvider courseId={defaultProps.courseId}>
|
||||
<IntlProvider locale="en">
|
||||
{children}
|
||||
</IntlProvider>
|
||||
</PagesAndResourcesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
let axiosMock;
|
||||
|
||||
describe('ProctoredExamSettings', () => {
|
||||
function setupApp(isAdmin = true) {
|
||||
@@ -44,15 +47,21 @@ describe('ProctoredExamSettings', () => {
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
store = initializeStore({
|
||||
models: {
|
||||
courseApps: {
|
||||
proctoring: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
|
||||
).reply(200, [
|
||||
{
|
||||
name: 'test_lti',
|
||||
verbose_name: 'LTI Provider',
|
||||
name: 'test_lti',
|
||||
verbose_name: 'LTI Provider',
|
||||
},
|
||||
]);
|
||||
axiosMock.onGet(
|
||||
@@ -144,9 +153,9 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByLabelText('Enable Proctored Exams');
|
||||
screen.getByText('Proctored exams');
|
||||
});
|
||||
const enabledProctoredExamCheck = screen.getByLabelText('Enable Proctored Exams');
|
||||
const enabledProctoredExamCheck = screen.getByLabelText('Proctored exams');
|
||||
expect(enabledProctoredExamCheck.checked).toEqual(false);
|
||||
expect(screen.queryByText('Allow Opting Out of Proctored Exams')).toBeNull();
|
||||
expect(screen.queryByDisplayValue('mockproc')).toBeNull();
|
||||
@@ -157,22 +166,22 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
it('Hides all other fields when enableProctoredExams toggled to false', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByLabelText('Enable Proctored Exams');
|
||||
screen.getByText('Proctored exams');
|
||||
});
|
||||
expect(screen.queryByText('Allow Opting Out of Proctored Exams')).toBeDefined();
|
||||
expect(screen.queryByText('Allow opting out of proctored exams')).toBeDefined();
|
||||
expect(screen.queryByDisplayValue('mockproc')).toBeDefined();
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeDefined();
|
||||
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeDefined();
|
||||
|
||||
let enabledProctorExamCheck = screen.getByLabelText('Enable Proctored Exams');
|
||||
expect(enabledProctorExamCheck.checked).toEqual(true);
|
||||
let enabledProctoredExamCheck = screen.getAllByLabelText('Proctored exams', { exact: false })[0];
|
||||
expect(enabledProctoredExamCheck.checked).toEqual(true);
|
||||
await act(async () => {
|
||||
fireEvent.click(enabledProctorExamCheck, { target: { value: false } });
|
||||
fireEvent.click(enabledProctoredExamCheck, { target: { value: false } });
|
||||
});
|
||||
enabledProctorExamCheck = screen.getByLabelText('Enable Proctored Exams');
|
||||
expect(enabledProctorExamCheck.checked).toEqual(false);
|
||||
expect(screen.queryByText('Allow Opting Out of Proctored Exams')).toBeNull();
|
||||
enabledProctoredExamCheck = screen.getByLabelText('Proctored exams');
|
||||
expect(enabledProctoredExamCheck.checked).toEqual(false);
|
||||
expect(screen.queryByText('Allow opting out of proctored exams')).toBeNull();
|
||||
expect(screen.queryByDisplayValue('mockproc')).toBeNull();
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||
@@ -187,13 +196,15 @@ describe('ProctoredExamSettings', () => {
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
expect(screen.queryByTestId('allowOptingOutRadio')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation with invalid escalation email', () => {
|
||||
const proctoringProvidersRequiringEscalationEmail = ['proctortrack', 'test_lti'];
|
||||
|
||||
beforeEach(async () => {
|
||||
axiosMock.onGet(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
@@ -205,196 +216,198 @@ describe('ProctoredExamSettings', () => {
|
||||
proctoring_escalation_email: 'test@example.com',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
|
||||
course_start_date: '2070-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
axiosMock.onPatch(
|
||||
ExamsApiService.getExamConfigurationUrl(defaultProps.courseId),
|
||||
).reply(204, {});
|
||||
|
||||
axiosMock.onPost(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, {});
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
});
|
||||
|
||||
it('Creates an alert when no proctoring escalation email is provided with proctortrack selected', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
proctoringProvidersRequiringEscalationEmail.forEach(provider => {
|
||||
it(`Creates an alert when no proctoring escalation email is provided with ${provider} selected`, async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||
expect(escalationEmailError.textContent).not.toBeNull();
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
|
||||
// verify alert link links to offending input
|
||||
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
||||
await act(async () => {
|
||||
fireEvent.click(errorLink);
|
||||
});
|
||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||
});
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('proctortrackEscalationEmailError');
|
||||
expect(escalationEmailError.textContent).not.toBeNull();
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
it(`Creates an alert when invalid proctoring escalation email is provided with ${provider} selected`, async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
|
||||
// verify alert link links to offending input
|
||||
const errorLink = screen.getByTestId('proctorTrackEscalationEmailErrorLink');
|
||||
await act(async () => {
|
||||
fireEvent.click(errorLink);
|
||||
});
|
||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||
});
|
||||
const selectElement = screen.getByDisplayValue('proctortrack');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: provider } });
|
||||
});
|
||||
|
||||
it('Creates an alert when invalid proctoring escalation email is provided with proctortrack selected', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
expect(escalationEmailError.textContent).not.toBeNull();
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
|
||||
// verify alert link links to offending input
|
||||
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
||||
await act(async () => {
|
||||
fireEvent.click(errorLink);
|
||||
});
|
||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||
});
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('proctortrackEscalationEmailError');
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
expect(escalationEmailError.textContent).not.toBeNull();
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
it('Creates an alert when invalid proctoring escalation email is provided with proctoring disabled', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
});
|
||||
const enableProctoringElement = screen.getByText('Proctored exams');
|
||||
await act(async () => fireEvent.click(enableProctoringElement));
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
|
||||
// verify alert link links to offending input
|
||||
const errorLink = screen.getByTestId('proctorTrackEscalationEmailErrorLink');
|
||||
await act(async () => {
|
||||
fireEvent.click(errorLink);
|
||||
});
|
||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||
});
|
||||
|
||||
it('Creates an alert when invalid proctoring escalation email is provided with proctoring disabled', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
});
|
||||
const enableProctoringElement = screen.getByLabelText('Enable Proctored Exams');
|
||||
await act(async () => fireEvent.click(enableProctoringElement));
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
expect(escalationEmailError.textContent).not.toBeNull();
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
});
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('proctortrackEscalationEmailError');
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
expect(escalationEmailError.textContent).not.toBeNull();
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
});
|
||||
it('Has no error when empty proctoring escalation email is provided with proctoring disabled', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
const enableProctoringElement = screen.getByText('Proctored exams');
|
||||
await act(async () => fireEvent.click(enableProctoringElement));
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
|
||||
it('Has no error when invalid proctoring escalation email is provided with proctoring disabled', async () => {
|
||||
axiosMock.onPost(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, 'success');
|
||||
axiosMock.onPatch(
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||
).reply(200, 'success');
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
const enableProctoringElement = screen.getByLabelText('Enable Proctored Exams');
|
||||
await act(async () => fireEvent.click(enableProctoringElement));
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
|
||||
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||
expect(screen.queryByTestId('proctortrackEscalationEmailError')).toBeNull();
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it('Has no error when valid proctoring escalation email is provided with proctortrack selected', async () => {
|
||||
axiosMock.onPost(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, 'success');
|
||||
axiosMock.onPatch(
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||
).reply(200, 'success');
|
||||
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
});
|
||||
|
||||
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||
expect(screen.queryByTestId('proctortrackEscalationEmailError')).toBeNull();
|
||||
it(`Escalation email Field Show when proctoring backend is switched back to ${provider}`, async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
|
||||
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||
});
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it('Escalation email field hidden when proctoring backend is not Proctortrack', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
it('Submits form when "Enter" key is hit in the escalation email field', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.submit(selectEscalationEmailElement);
|
||||
});
|
||||
// if the error appears, the form has been submitted
|
||||
expect(screen.getByTestId('escalationEmailError')).toBeDefined();
|
||||
});
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
});
|
||||
|
||||
it('Escalation email Field Show when proctoring backend is switched back to Proctortrack', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
|
||||
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||
});
|
||||
|
||||
it('Submits form when "Enter" key is hit in the escalation email field', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.submit(selectEscalationEmailElement);
|
||||
});
|
||||
// if the error appears, the form has been submitted
|
||||
expect(screen.getByTestId('proctortrackEscalationEmailError')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -464,8 +477,10 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Does not include lti_external as a selectable option', async () => {
|
||||
const courseData = mockGetFutureCourseData;
|
||||
courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc'];
|
||||
const courseData = {
|
||||
...mockGetFutureCourseData,
|
||||
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
|
||||
};
|
||||
mockCourseData(courseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
@@ -475,8 +490,10 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Includes lti proctoring provider options when lti_external is allowed by studio', async () => {
|
||||
const courseData = mockGetFutureCourseData;
|
||||
courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc'];
|
||||
const courseData = {
|
||||
...mockGetFutureCourseData,
|
||||
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
|
||||
};
|
||||
mockCourseData(courseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
@@ -487,6 +504,19 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
|
||||
it('Does not include lti provider options when lti_external is not available in studio', async () => {
|
||||
const isAdmin = true;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
|
||||
const providerOption = screen.queryByTestId('test_lti');
|
||||
expect(providerOption).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Does not request lti provider options if there is no exam service url configuration', async () => {
|
||||
mergeConfig({
|
||||
EXAMS_BASE_URL: null,
|
||||
@@ -513,7 +543,7 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByText('Proctoring Provider');
|
||||
screen.getByText('Proctoring provider');
|
||||
});
|
||||
|
||||
// make sure test_lti is the selected provider
|
||||
@@ -594,27 +624,16 @@ describe('ProctoredExamSettings', () => {
|
||||
).reply(200, 'success');
|
||||
});
|
||||
|
||||
it('Show spinner while saving', async () => {
|
||||
axiosMock.onPost(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, 'success');
|
||||
|
||||
it('Disable button while submitting', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
let submitButton = screen.getByTestId('submissionButton');
|
||||
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
const submitSpinner = screen.getByTestId('saveInProgress');
|
||||
expect(submitSpinner).toBeDefined();
|
||||
|
||||
await waitForElementToBeRemoved(submitSpinner);
|
||||
// request studio settings, exam config, and exam service providers
|
||||
expect(axiosMock.history.get.length).toBe(3);
|
||||
expect(axiosMock.history.post.length).toBe(1); // studio
|
||||
expect(axiosMock.history.patch.length).toBe(1); // edx-exams
|
||||
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
|
||||
submitButton = screen.getByTestId('submissionButton');
|
||||
expect(submitButton).toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
|
||||
@@ -681,11 +700,19 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the provider to proctortrack and set the email
|
||||
// Make a change to the provider to test_lti and set the email
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
});
|
||||
|
||||
const escalationEmail = screen.getByTestId('escalationEmail');
|
||||
expect(escalationEmail.value).toEqual('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
|
||||
});
|
||||
expect(escalationEmail.value).toEqual('test_lti@example.com');
|
||||
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
@@ -695,6 +722,7 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(axiosMock.history.patch.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
||||
provider: 'test_lti',
|
||||
escalation_email: 'test_lti@example.com',
|
||||
});
|
||||
|
||||
// update studio settings
|
||||
@@ -725,6 +753,7 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(axiosMock.history.patch.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
||||
provider: null,
|
||||
escalation_email: null,
|
||||
});
|
||||
expect(axiosMock.history.patch.length).toBe(1);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
@@ -861,9 +890,6 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the proctoring provider
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
@@ -53,7 +53,7 @@ const messages = defineMessages({
|
||||
},
|
||||
'authoring.proctoring.escalationemail.label': {
|
||||
id: 'authoring.proctoring.escalationemail.label',
|
||||
defaultMessage: 'Proctortrack escalation email',
|
||||
defaultMessage: 'Escalation email',
|
||||
description: 'Label for escalation email text field',
|
||||
},
|
||||
'authoring.proctoring.escalationemail.help': {
|
||||
@@ -63,12 +63,12 @@ const messages = defineMessages({
|
||||
},
|
||||
'authoring.proctoring.escalationemail.error.blank': {
|
||||
id: 'authoring.proctoring.escalationemail.error.blank',
|
||||
defaultMessage: 'The Proctortrack Escalation Email field cannot be empty if proctortrack is the selected provider.',
|
||||
defaultMessage: 'The Escalation Email field cannot be empty if {proctoringProviderName} is the selected provider.',
|
||||
description: 'Error message for missing required email field.',
|
||||
},
|
||||
'authoring.proctoring.escalationemail.error.invalid': {
|
||||
id: 'authoring.proctoring.escalationemail.error.invalid',
|
||||
defaultMessage: 'The Proctortrack Escalation Email field is in the wrong format and is not valid.',
|
||||
defaultMessage: 'The Escalation Email field is in the wrong format and is not valid.',
|
||||
description: 'Error message for a invalid email format.',
|
||||
},
|
||||
'authoring.proctoring.allowoptout.label': {
|
||||
20
plugins/course-apps/proctoring/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-proctoring",
|
||||
"version": "0.1.0",
|
||||
"description": "Proctoring configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"classnames": "*",
|
||||
"email-validator": "*",
|
||||
"react": "*",
|
||||
"prop-types": "*",
|
||||
"moment": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,17 @@ import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import FormSwitchGroup from '../../generic/FormSwitchGroup';
|
||||
import { useAppSetting } from '../../utils';
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||
import { useAppSetting } from 'CourseAuthoring/utils';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
function ProgressSettings({ intl, onClose }) {
|
||||
const ProgressSettings = ({ intl, onClose }) => {
|
||||
const [disableProgressGraph, saveSetting] = useAppSetting('disableProgressGraph');
|
||||
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toLowerCase() === 'true';
|
||||
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toString().toLowerCase() === 'true';
|
||||
|
||||
const handleSettingsSave = (values) => {
|
||||
if (showProgressGraphSetting) { saveSetting(!values.enableProgressGraph); }
|
||||
const handleSettingsSave = async (values) => {
|
||||
if (showProgressGraphSetting) { await saveSetting(!values.enableProgressGraph); }
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -31,21 +31,21 @@ function ProgressSettings({ intl, onClose }) {
|
||||
{
|
||||
({ handleChange, handleBlur, values }) => (
|
||||
showProgressGraphSetting && (
|
||||
<FormSwitchGroup
|
||||
id="enable-progress-graph"
|
||||
name="enableProgressGraph"
|
||||
label={intl.formatMessage(messages.enableGraphLabel)}
|
||||
helpText={intl.formatMessage(messages.enableGraphHelp)}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
checked={values.enableProgressGraph}
|
||||
/>
|
||||
<FormSwitchGroup
|
||||
id="enable-progress-graph"
|
||||
name="enableProgressGraph"
|
||||
label={intl.formatMessage(messages.enableGraphLabel)}
|
||||
helpText={intl.formatMessage(messages.enableGraphHelp)}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
checked={values.enableProgressGraph}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
</AppSettingsModal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ProgressSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
18
plugins/course-apps/progress/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-progress",
|
||||
"version": "0.1.0",
|
||||
"description": "Progress configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Form, TransitionReplace } from '@edx/paragon';
|
||||
import { Button, Form, TransitionReplace } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useState } from 'react';
|
||||
import { GroupTypes, TeamSizes } from '../../data/constants';
|
||||
import { GroupTypes, TeamSizes } from 'CourseAuthoring/data/constants';
|
||||
|
||||
import CollapsableEditor from '../../generic/CollapsableEditor';
|
||||
import FormikControl from '../../generic/FormikControl';
|
||||
import CollapsableEditor from 'CourseAuthoring/generic/CollapsableEditor';
|
||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||
import messages from './messages';
|
||||
import { isGroupTypeEnabled } from './utils';
|
||||
|
||||
// Maps a team type to its corresponding intl message
|
||||
const TeamTypeNameMessage = {
|
||||
@@ -14,6 +15,10 @@ const TeamTypeNameMessage = {
|
||||
label: messages.groupTypeOpen,
|
||||
description: messages.groupTypeOpenDescription,
|
||||
},
|
||||
[GroupTypes.OPEN_MANAGED]: {
|
||||
label: messages.groupTypeOpenManaged,
|
||||
description: messages.groupTypeOpenManagedDescription,
|
||||
},
|
||||
[GroupTypes.PUBLIC_MANAGED]: {
|
||||
label: messages.groupTypePublicManaged,
|
||||
description: messages.groupTypePublicManagedDescription,
|
||||
@@ -24,9 +29,9 @@ const TeamTypeNameMessage = {
|
||||
},
|
||||
};
|
||||
|
||||
function GroupEditor({
|
||||
const GroupEditor = ({
|
||||
intl, group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
|
||||
}) {
|
||||
}) => {
|
||||
const [isDeleting, setDeleting] = useState(false);
|
||||
const [isOpen, setOpen] = useState(group.id === null);
|
||||
const initiateDeletion = () => setDeleting(true);
|
||||
@@ -105,7 +110,7 @@ function GroupEditor({
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
{Object.values(GroupTypes).map(groupType => (
|
||||
{Object.values(GroupTypes).map(groupType => isGroupTypeEnabled(groupType) && (
|
||||
<Form.Radio
|
||||
key={groupType}
|
||||
value={groupType}
|
||||
@@ -133,7 +138,7 @@ function GroupEditor({
|
||||
)}
|
||||
</TransitionReplace>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const groupShape = PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
102
plugins/course-apps/teams/GroupEditor.test.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { useFormikContext } from 'formik';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import GroupEditor from './GroupEditor';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('formik', () => ({
|
||||
...jest.requireActual('formik'),
|
||||
useFormikContext: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('GroupEditor', () => {
|
||||
const mockIntl = { formatMessage: jest.fn() };
|
||||
|
||||
const mockGroup = {
|
||||
id: '1',
|
||||
name: 'Test Group',
|
||||
description: 'Test Group Description',
|
||||
type: 'open',
|
||||
maxTeamSize: 5,
|
||||
};
|
||||
|
||||
const mockProps = {
|
||||
intl: mockIntl,
|
||||
fieldNameCommonBase: 'test',
|
||||
group: mockGroup,
|
||||
onDelete: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
onBlur: jest.fn(),
|
||||
errors: {},
|
||||
};
|
||||
|
||||
const renderComponent = (overrideProps = {}) => render(
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<GroupEditor {...mockProps} {...overrideProps} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
useFormikContext.mockReturnValue({
|
||||
touched: {},
|
||||
errors: {},
|
||||
handleChange: jest.fn(),
|
||||
handleBlur: jest.fn(),
|
||||
setFieldError: jest.fn(),
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders without errors', () => {
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test('renders the group name and description', () => {
|
||||
const { getByText } = renderComponent();
|
||||
expect(getByText('Test Group')).toBeInTheDocument();
|
||||
expect(getByText('Test Group Description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('group types messages', () => {
|
||||
test('group type open message', () => {
|
||||
const { getByLabelText, getByText } = renderComponent();
|
||||
const expandButton = getByLabelText('Expand group editor');
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
fireEvent.click(expandButton);
|
||||
expect(getByText(messages.groupTypeOpenDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('group type public_managed message', () => {
|
||||
const publicManagedGroupMock = {
|
||||
id: '2',
|
||||
name: 'Test Group',
|
||||
description: 'Test Group Description',
|
||||
type: 'public_managed',
|
||||
maxTeamSize: 5,
|
||||
};
|
||||
const { getByLabelText, getByText } = renderComponent({ group: publicManagedGroupMock });
|
||||
const expandButton = getByLabelText('Expand group editor');
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
fireEvent.click(expandButton);
|
||||
expect(getByText(messages.groupTypePublicManagedDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('group type private_managed message', () => {
|
||||
const privateManagedGroupMock = {
|
||||
id: '3',
|
||||
name: 'Test Group',
|
||||
description: 'Test Group Description',
|
||||
type: 'private_managed',
|
||||
maxTeamSize: 5,
|
||||
};
|
||||
const { getByLabelText, getByText } = renderComponent({ group: privateManagedGroupMock });
|
||||
const expandButton = getByLabelText('Expand group editor');
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
fireEvent.click(expandButton);
|
||||
expect(getByText(messages.groupTypePrivateManagedDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,25 @@
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Form } from '@edx/paragon';
|
||||
import { Add } from '@edx/paragon/icons';
|
||||
import { Button, Form } from '@openedx/paragon';
|
||||
import { Add } from '@openedx/paragon/icons';
|
||||
|
||||
import { FieldArray } from 'formik';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import * as Yup from 'yup';
|
||||
import { GroupTypes, TeamSizes } from '../../data/constants';
|
||||
import FormikControl from '../../generic/FormikControl';
|
||||
import { setupYupExtensions, useAppSetting } from '../../utils';
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import { GroupTypes, TeamSizes } from 'CourseAuthoring/data/constants';
|
||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||
import { setupYupExtensions, useAppSetting } from 'CourseAuthoring/utils';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import GroupEditor from './GroupEditor';
|
||||
import messages from './messages';
|
||||
|
||||
setupYupExtensions();
|
||||
|
||||
function TeamSettings({
|
||||
const TeamSettings = ({
|
||||
intl,
|
||||
onClose,
|
||||
}) {
|
||||
}) => {
|
||||
const [teamsConfiguration, saveSettings] = useAppSetting('teamsConfiguration');
|
||||
const blankNewGroup = {
|
||||
name: '',
|
||||
@@ -161,7 +161,7 @@ function TeamSettings({
|
||||
}
|
||||
</AppSettingsModal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
TeamSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
@@ -93,6 +93,14 @@ const messages = defineMessages({
|
||||
id: 'authoring.pagesAndResources.teams.group.types.open',
|
||||
defaultMessage: 'Open',
|
||||
},
|
||||
groupTypeOpenManaged: {
|
||||
id: 'authoring.pagesAndResources.teams.group.types.open_managed',
|
||||
defaultMessage: 'Open managed',
|
||||
},
|
||||
groupTypeOpenManagedDescription: {
|
||||
id: 'authoring.pagesAndResources.teams.group.types.open_managed.description',
|
||||
defaultMessage: 'Only course staff can create teams. Learners can see, join and leave teams.',
|
||||
},
|
||||
groupTypeOpenDescription: {
|
||||
id: 'authoring.pagesAndResources.teams.group.types.open.description',
|
||||
defaultMessage: 'Learners can create, join, leave, and see other teams',
|
||||
20
plugins/course-apps/teams/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-teams",
|
||||
"version": "0.1.0",
|
||||
"description": "Teams configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"formik": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"uuid": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
23
plugins/course-apps/teams/utils.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
||||
|
||||
/**
|
||||
* Check if a group type is enabled by the current configuration.
|
||||
* This is a temporary workaround to disable the OPEN MANAGED team type until it is fully adopted.
|
||||
* For more information, see: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3885760525/Open+Managed+Group+Type
|
||||
* @param {string} groupType - the group type to check
|
||||
* @returns {boolean} - true if the group type is enabled
|
||||
*/
|
||||
export const isGroupTypeEnabled = (groupType) => {
|
||||
const enabledTypesByDefault = [
|
||||
GroupTypes.OPEN,
|
||||
GroupTypes.PUBLIC_MANAGED,
|
||||
GroupTypes.PRIVATE_MANAGED,
|
||||
];
|
||||
const enabledTypesByConfig = {
|
||||
[GroupTypes.OPEN_MANAGED]: getConfig().ENABLE_OPEN_MANAGED_TEAM_TYPE,
|
||||
};
|
||||
return enabledTypesByDefault.includes(groupType) || enabledTypesByConfig[groupType] || false;
|
||||
};
|
||||
39
plugins/course-apps/teams/utils.test.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
||||
import { isGroupTypeEnabled } from './utils';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() }));
|
||||
|
||||
describe('teams utils', () => {
|
||||
describe('isGroupTypeEnabled', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('returns true if the group type is enabled', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false });
|
||||
expect(isGroupTypeEnabled(GroupTypes.OPEN)).toBe(true);
|
||||
expect(isGroupTypeEnabled(GroupTypes.PUBLIC_MANAGED)).toBe(true);
|
||||
expect(isGroupTypeEnabled(GroupTypes.PRIVATE_MANAGED)).toBe(true);
|
||||
});
|
||||
test('returns false if the OPEN_MANAGED group is not enabled', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false });
|
||||
expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true if the OPEN_MANAGED group is enabled', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
|
||||
expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false if the group is invalid', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
|
||||
expect(isGroupTypeEnabled('FOO')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false if the group is null', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
|
||||
expect(isGroupTypeEnabled(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,12 @@ import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import FormSwitchGroup from '../../generic/FormSwitchGroup';
|
||||
import { useAppSetting } from '../../utils';
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||
import { useAppSetting } from 'CourseAuthoring/utils';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
function WikiSettings({ intl, onClose }) {
|
||||
const WikiSettings = ({ intl, onClose }) => {
|
||||
const [enablePublicWiki, saveSetting] = useAppSetting('allowPublicWikiAccess');
|
||||
const handleSettingsSave = (values) => saveSetting(values.enablePublicWiki);
|
||||
|
||||
@@ -39,7 +39,7 @@ function WikiSettings({ intl, onClose }) {
|
||||
}
|
||||
</AppSettingsModal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
WikiSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
18
plugins/course-apps/wiki/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-wiki",
|
||||
"version": "0.1.0",
|
||||
"description": "Wiki configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
4
plugins/course-apps/xpert_unit_summary/README.rst
Normal file
@@ -0,0 +1,4 @@
|
||||
Xpert Unit Summaries Configuration Plugin
|
||||
=========================================
|
||||
|
||||
Install this using ``npm install plugins/course-apps/xpert_unit_summary/ --no-save``.
|
||||
45
plugins/course-apps/xpert_unit_summary/Settings.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { useCallback, useContext, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import SettingsModal from './settings-modal/SettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
import { fetchXpertSettings } from './data/thunks';
|
||||
|
||||
const XpertUnitSummarySettings = ({ intl }) => {
|
||||
const { path: pagesAndResourcesPath, courseId } = useContext(PagesAndResourcesContext);
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchXpertSettings(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
navigate(pagesAndResourcesPath);
|
||||
}, [pagesAndResourcesPath]);
|
||||
|
||||
return (
|
||||
<SettingsModal
|
||||
appId="xpert-unit-summary"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableXpertUnitSummaryHelp)}
|
||||
helpPrivacyText={intl.formatMessage(messages.enableXpertUnitSummaryHelpPrivacyLink)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableXpertUnitSummaryLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableXpertUnitSummaryLink)}
|
||||
allUnitsEnabledText={intl.formatMessage(messages.allUnitsEnabledByDefault)}
|
||||
noUnitsEnabledText={intl.formatMessage(messages.noUnitsEnabledByDefault)}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
XpertUnitSummarySettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(XpertUnitSummarySettings);
|
||||
281
plugins/course-apps/xpert_unit_summary/Settings.test.jsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import ReactDOM from 'react-dom';
|
||||
import React from 'react';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import {
|
||||
getConfig, initializeMockApp, setConfig,
|
||||
} from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||
import {
|
||||
queryByTestId, render, waitFor, getByText, fireEvent,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
|
||||
import XpertUnitSummarySettings from './Settings';
|
||||
import * as API from './data/api';
|
||||
import * as Thunks from './data/thunks';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
let axiosMock;
|
||||
let store;
|
||||
let container;
|
||||
|
||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
||||
ReactDOM.createPortal = jest.fn(node => node);
|
||||
|
||||
function renderComponent() {
|
||||
const wrapper = render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<PagesAndResourcesProvider courseId={courseId}>
|
||||
<MemoryRouter initialEntries={['/xpert-unit-summary/settings']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/xpert-unit-summary/settings"
|
||||
element={<PageWrap><XpertUnitSummarySettings courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={<PageWrap><div /></PageWrap>}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PagesAndResourcesProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
function generateCourseLevelAPIResponse({
|
||||
success, enabled,
|
||||
}) {
|
||||
return {
|
||||
response: {
|
||||
success, enabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('XpertUnitSummarySettings', () => {
|
||||
beforeEach(() => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
BASE_URL: 'http://test.edx.org',
|
||||
LMS_BASE_URL: 'http://lmstest.edx.org',
|
||||
CMS_BASE_URL: 'http://cmstest.edx.org',
|
||||
LOGIN_URL: 'http://support.edx.org/login',
|
||||
LOGOUT_URL: 'http://support.edx.org/logout',
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://support.edx.org/access_token',
|
||||
ACCESS_TOKEN_COOKIE_NAME: 'cookie',
|
||||
CSRF_TOKEN_API_PATH: '/',
|
||||
SUPPORT_URL: 'http://support.edx.org',
|
||||
});
|
||||
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore({
|
||||
models: {
|
||||
courseDetails: {
|
||||
[courseId]: {
|
||||
start: Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
describe('with successful network connections', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test('Shows switch on if enabled from backend', async () => {
|
||||
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).toBeTruthy();
|
||||
expect(queryByTestId(container, 'enable-badge')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Shows switch on if disabled from backend', async () => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: false,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).toBeTruthy();
|
||||
expect(queryByTestId(container, 'enable-badge')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Shows enable radio selected if enabled from backend', async () => {
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
expect(queryByTestId(container, 'enable-radio').checked).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Shows disable radio selected if enabled from backend', async () => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: false,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
expect(queryByTestId(container, 'disable-radio').checked).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('first time course configuration', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(400, generateCourseLevelAPIResponse({
|
||||
success: false,
|
||||
enabled: undefined,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test('Does not show as enabled if configuration does not exist', async () => {
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).not.toBeTruthy();
|
||||
expect(queryByTestId(container, 'enable-badge')).not.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saving configuration changes', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: false,
|
||||
}));
|
||||
|
||||
axiosMock.onPost(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test('Saving configuration changes', async () => {
|
||||
jest.spyOn(API, 'postXpertSettings');
|
||||
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
expect(queryByTestId(container, 'disable-radio').checked).toBeTruthy();
|
||||
fireEvent.click(queryByTestId(container, 'enable-radio'));
|
||||
fireEvent.click(getByText(container, 'Save'));
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
expect(API.postXpertSettings).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('testing configurable gating', () => {
|
||||
beforeEach(async () => {
|
||||
axiosMock.onGet(API.getXpertConfigurationStatusUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: true,
|
||||
}));
|
||||
jest.spyOn(API, 'getXpertPluginConfigurable');
|
||||
await executeThunk(Thunks.fetchXpertPluginConfigurable(courseId), store.dispatch);
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test('getting Xpert Plugin configurable status', () => {
|
||||
expect(API.getXpertPluginConfigurable).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removing course configuration', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
axiosMock.onDelete(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: undefined,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test('Deleting course configuration', async () => {
|
||||
jest.spyOn(API, 'deleteXpertSettings');
|
||||
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
fireEvent.click(container.querySelector('#enable-xpert-unit-summary-toggle'));
|
||||
fireEvent.click(getByText(container, 'Save'));
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
expect(API.deleteXpertSettings).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetting course units', () => {
|
||||
test('reset all units to be enabled', async () => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
axiosMock.onPost(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
|
||||
jest.spyOn(API, 'postXpertSettings');
|
||||
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
fireEvent.click(queryByTestId(container, 'reset-units'));
|
||||
expect(API.postXpertSettings).toBeCalledWith(courseId, { reset: true, enabled: true });
|
||||
});
|
||||
|
||||
test('reset all units to be disabled', async () => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: false,
|
||||
}));
|
||||
|
||||
axiosMock.onPost(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: false,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
|
||||
jest.spyOn(API, 'postXpertSettings');
|
||||
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
fireEvent.click(queryByTestId(container, 'reset-units'));
|
||||
expect(API.postXpertSettings).toBeCalledWith(courseId, { reset: true, enabled: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
13
plugins/course-apps/xpert_unit_summary/appInfo.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
id: 'xpert-unit-summary',
|
||||
enabled: false,
|
||||
name: 'Xpert unit summaries',
|
||||
description: 'Use generative AI to summarize course content and reinforce learning.',
|
||||
allowedOperations: {
|
||||
enable: true,
|
||||
configure: true,
|
||||
},
|
||||
documentationLinks: {
|
||||
learnMoreConfiguration: 'https://openai.com/',
|
||||
},
|
||||
};
|
||||
41
plugins/course-apps/xpert_unit_summary/data/api.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
export function getXpertSettingsUrl(courseId) {
|
||||
return `${getConfig().STUDIO_BASE_URL}/ai_aside/v1/${courseId}`;
|
||||
}
|
||||
|
||||
export function getXpertConfigurationStatusUrl(courseId) {
|
||||
return `${getConfig().STUDIO_BASE_URL}/ai_aside/v1/${courseId}/configurable`;
|
||||
}
|
||||
|
||||
export async function getXpertSettings(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getXpertSettingsUrl(courseId));
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function postXpertSettings(courseId, state) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXpertSettingsUrl(courseId), {
|
||||
enabled: state.enabled,
|
||||
reset: state.reset,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getXpertPluginConfigurable(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getXpertConfigurationStatusUrl(courseId));
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteXpertSettings(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.delete(getXpertSettingsUrl(courseId));
|
||||
|
||||
return data;
|
||||
}
|
||||
113
plugins/course-apps/xpert_unit_summary/data/thunks.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import { updateSavingStatus, updateLoadingStatus, updateResetStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import { addModel, updateModel } from 'CourseAuthoring/generic/model-store';
|
||||
|
||||
import {
|
||||
getXpertSettings, postXpertSettings, getXpertPluginConfigurable, deleteXpertSettings,
|
||||
} from './api';
|
||||
|
||||
export function updateXpertSettings(courseId, state) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
try {
|
||||
const { response } = await postXpertSettings(courseId, state);
|
||||
const { success } = response;
|
||||
if (success) {
|
||||
dispatch(updateModel({ modelType: 'XpertSettings', model: { id: 'xpert-unit-summary', enabled: state.enabled } }));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
}
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchXpertPluginConfigurable(courseId) {
|
||||
return async (dispatch) => {
|
||||
let enabled;
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.PENDING }));
|
||||
try {
|
||||
const { response } = await getXpertPluginConfigurable(courseId);
|
||||
enabled = response?.enabled;
|
||||
} catch (e) {
|
||||
enabled = undefined;
|
||||
}
|
||||
|
||||
dispatch(addModel({
|
||||
modelType: 'XpertSettings.enabled',
|
||||
model: {
|
||||
id: 'xpert-unit-summary',
|
||||
enabled,
|
||||
},
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchXpertSettings(courseId) {
|
||||
return async (dispatch) => {
|
||||
let enabled;
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.PENDING }));
|
||||
|
||||
try {
|
||||
const { response } = await getXpertSettings(courseId);
|
||||
enabled = response?.enabled;
|
||||
} catch (e) {
|
||||
enabled = undefined;
|
||||
}
|
||||
|
||||
dispatch(addModel({
|
||||
modelType: 'XpertSettings',
|
||||
model: {
|
||||
id: 'xpert-unit-summary',
|
||||
enabled,
|
||||
},
|
||||
}));
|
||||
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
};
|
||||
}
|
||||
|
||||
export function removeXpertSettings(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
|
||||
try {
|
||||
const { response } = await deleteXpertSettings(courseId);
|
||||
const { success } = response;
|
||||
if (success) {
|
||||
const model = { id: 'xpert-unit-summary', enabled: undefined };
|
||||
dispatch(updateModel({ modelType: 'XpertSettings', model }));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
}
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resetXpertSettings(courseId, state) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateResetStatus({ status: RequestStatus.PENDING }));
|
||||
try {
|
||||
const { response } = await postXpertSettings(courseId, state);
|
||||
const { success } = response;
|
||||
if (success) {
|
||||
dispatch(updateResetStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
}
|
||||
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
34
plugins/course-apps/xpert_unit_summary/messages.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.pages-resources.xpert-unit-summary.heading',
|
||||
defaultMessage: 'Configure Xpert unit summaries',
|
||||
},
|
||||
enableXpertUnitSummaryLabel: {
|
||||
id: 'course-authoring.pages-resources.xpert-unit-summary.enable-xpert-unit-summary.label',
|
||||
defaultMessage: 'Xpert unit summaries',
|
||||
},
|
||||
enableXpertUnitSummaryHelp: {
|
||||
id: 'course-authoring.pages-resources.xpert-unit-summary.enable-xpert-unit-summary.help',
|
||||
defaultMessage: 'Reinforce learning concepts by sharing text-based course content with OpenAI (via API) to display unit summaries on-demand for learners. Learners can leave feedback about the quality of the AI-generated summaries for use by edX to improve the performance of the tool.',
|
||||
},
|
||||
enableXpertUnitSummaryHelpPrivacyLink: {
|
||||
id: 'course-authoring.pages-resources.xpert-unit-summary.enable-xpert-unit-summary.help.privacylink',
|
||||
defaultMessage: 'Learn more about OpenAI API data privacy.',
|
||||
},
|
||||
enableXpertUnitSummaryLink: {
|
||||
id: 'course-authoring.pages-resources.xpert-unit-summary.enable-xpert-unit-summary.link',
|
||||
defaultMessage: 'Learn more about how OpenAI handles data',
|
||||
},
|
||||
allUnitsEnabledByDefault: {
|
||||
id: 'course-authoring.pages-resources.xpert-unit-summary.all-units-enabled-by-default',
|
||||
defaultMessage: 'All units enabled by default',
|
||||
},
|
||||
noUnitsEnabledByDefault: {
|
||||
id: 'course-authoring.pages-resources.xpert-unit-summary.no-units-enabled-by-default',
|
||||
defaultMessage: 'No units enabled by default',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
21
plugins/course-apps/xpert_unit_summary/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-xpert_unit_summary",
|
||||
"version": "0.1.0",
|
||||
"description": "Xpert Unit Summaries configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"formik": "*",
|
||||
"prop-types": "*",
|
||||
"yup": "*",
|
||||
"react": "*",
|
||||
"react-redux": "*",
|
||||
"react-router-dom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
const ResetIcon = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
transform="scale(-1,1)"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default ResetIcon;
|
||||
@@ -0,0 +1,453 @@
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
Alert,
|
||||
Badge,
|
||||
Form,
|
||||
Icon,
|
||||
ModalDialog,
|
||||
OverlayTrigger,
|
||||
StatefulButton,
|
||||
Tooltip,
|
||||
TransitionReplace,
|
||||
Hyperlink,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
Info, CheckCircleOutline, SpinnerSimple,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { Formik } from 'formik';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {
|
||||
useContext, useEffect, useRef, useState,
|
||||
} from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
|
||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||
import Loading from 'CourseAuthoring/generic/Loading';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
||||
import { useIsMobile } from 'CourseAuthoring/utils';
|
||||
import { getLoadingStatus, getSavingStatus, getResetStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
|
||||
import { updateSavingStatus, updateResetStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
|
||||
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
|
||||
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
|
||||
import { updateXpertSettings, resetXpertSettings, removeXpertSettings } from '../data/thunks';
|
||||
import messages from './messages';
|
||||
import appInfo from '../appInfo';
|
||||
import ResetIcon from './ResetIcon';
|
||||
|
||||
import './SettingsModal.scss';
|
||||
|
||||
const AppSettingsForm = ({
|
||||
formikProps, children, showForm,
|
||||
}) => children && (
|
||||
<TransitionReplace>
|
||||
{showForm ? (
|
||||
<React.Fragment key="app-enabled">
|
||||
{children(formikProps)}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment key="app-disabled" />
|
||||
)}
|
||||
</TransitionReplace>
|
||||
);
|
||||
|
||||
AppSettingsForm.propTypes = {
|
||||
// Ignore the warning here since we're just passing along the props as-is and the child component should validate
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
formikProps: PropTypes.object.isRequired,
|
||||
showForm: PropTypes.bool.isRequired,
|
||||
children: PropTypes.func,
|
||||
};
|
||||
|
||||
AppSettingsForm.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
const SettingsModalBase = ({
|
||||
intl, title, onClose, variant, isMobile, children, footer,
|
||||
}) => (
|
||||
<ModalDialog
|
||||
title={title}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
variant={variant}
|
||||
hasCloseButton={isMobile}
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title data-testid="modal-title">
|
||||
{title}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
{children}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer className="p-4">
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{intl.formatMessage(messages.cancel)}
|
||||
</ModalDialog.CloseButton>
|
||||
{footer}
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</ModalDialog>
|
||||
);
|
||||
|
||||
SettingsModalBase.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
variant: PropTypes.oneOf(['default', 'dark']).isRequired,
|
||||
isMobile: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
footer: PropTypes.node,
|
||||
};
|
||||
|
||||
SettingsModalBase.defaultProps = {
|
||||
footer: null,
|
||||
};
|
||||
|
||||
const ResetUnitsButton = ({
|
||||
intl,
|
||||
courseId,
|
||||
checked,
|
||||
visible,
|
||||
}) => {
|
||||
const resetStatusRequestStatus = useSelector(getResetStatus);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (resetStatusRequestStatus === RequestStatus.SUCCESSFUL) {
|
||||
setTimeout(() => {
|
||||
dispatch(updateResetStatus({ status: '' }));
|
||||
}, 2000);
|
||||
}
|
||||
}, [resetStatusRequestStatus]);
|
||||
|
||||
const handleResetUnits = () => {
|
||||
dispatch(resetXpertSettings(courseId, { enabled: checked === 'true', reset: true }));
|
||||
};
|
||||
|
||||
const getResetButtonState = () => {
|
||||
switch (resetStatusRequestStatus) {
|
||||
case RequestStatus.PENDING:
|
||||
return 'pending';
|
||||
case RequestStatus.SUCCESSFUL:
|
||||
return 'finish';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible) { return null; }
|
||||
|
||||
const messageKey = checked === 'true' ? 'resetAllUnitsTooltipChecked' : 'resetAllUnitsTooltipUnchecked';
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
placement="right"
|
||||
overlay={(
|
||||
<Tooltip
|
||||
id={`tooltip-reset-${checked}`}
|
||||
className="reset-tooltip"
|
||||
>
|
||||
{intl.formatMessage(messages[messageKey])}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<StatefulButton
|
||||
className="reset-units-button"
|
||||
labels={{
|
||||
default: intl.formatMessage(messages.resetAllUnits),
|
||||
pending: '',
|
||||
finish: intl.formatMessage(messages.reset),
|
||||
}}
|
||||
icons={{
|
||||
default: <Icon src={ResetIcon} />,
|
||||
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
|
||||
finish: <Icon src={CheckCircleOutline} />,
|
||||
}}
|
||||
state={getResetButtonState()}
|
||||
onClick={handleResetUnits}
|
||||
disabledStates={['pending', 'finish']}
|
||||
variant="outline"
|
||||
data-testid="reset-units"
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
ResetUnitsButton.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
checked: PropTypes.oneOf(['true', 'false']).isRequired,
|
||||
visible: PropTypes.bool,
|
||||
};
|
||||
|
||||
ResetUnitsButton.defaultProps = {
|
||||
visible: false,
|
||||
};
|
||||
|
||||
const SettingsModal = ({
|
||||
intl,
|
||||
appId,
|
||||
title,
|
||||
children,
|
||||
configureBeforeEnable,
|
||||
initialValues,
|
||||
validationSchema,
|
||||
onClose,
|
||||
onSettingsSave,
|
||||
enableAppLabel,
|
||||
enableAppHelp,
|
||||
learnMoreText,
|
||||
helpPrivacyText,
|
||||
enableReinitialize,
|
||||
allUnitsEnabledText,
|
||||
noUnitsEnabledText,
|
||||
}) => {
|
||||
const { courseId } = useContext(PagesAndResourcesContext);
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
const updateSettingsRequestStatus = useSelector(getSavingStatus);
|
||||
const alertRef = useRef(null);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default';
|
||||
const isMobile = useIsMobile();
|
||||
const modalVariant = isMobile ? 'dark' : 'default';
|
||||
|
||||
const xpertSettings = useModel('XpertSettings', appId);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) {
|
||||
dispatch(updateSavingStatus({ status: '' }));
|
||||
onClose();
|
||||
}
|
||||
}, [updateSettingsRequestStatus]);
|
||||
|
||||
const handleFormSubmit = async ({ enabled, checked, ...rest }) => {
|
||||
let success;
|
||||
const values = { ...rest, enabled: enabled ? checked === 'true' : undefined };
|
||||
|
||||
if (enabled) {
|
||||
success = await dispatch(updateXpertSettings(courseId, values));
|
||||
} else {
|
||||
success = await dispatch(removeXpertSettings(courseId));
|
||||
}
|
||||
|
||||
if (onSettingsSave) {
|
||||
success = success && await onSettingsSave(values);
|
||||
}
|
||||
setSaveError(!success);
|
||||
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line no-unused-expressions
|
||||
};
|
||||
|
||||
const handleFormikSubmit = ({ handleSubmit, errors }) => async (event) => {
|
||||
// If submitting the form with errors, show the alert and scroll to it.
|
||||
await handleSubmit(event);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setSaveError(true);
|
||||
alertRef?.current.scrollIntoView?.(); // eslint-disable-line no-unused-expressions
|
||||
}
|
||||
};
|
||||
|
||||
const learnMoreLink = appInfo.documentationLinks?.learnMoreConfiguration && (
|
||||
<div className="py-1">
|
||||
<Hyperlink
|
||||
className="text-primary-500"
|
||||
destination={appInfo.documentationLinks.learnMoreConfiguration}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{learnMoreText}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
);
|
||||
|
||||
const helpPrivacyLink = (
|
||||
<div className="py-1">
|
||||
<Hyperlink
|
||||
className="text-primary-500"
|
||||
destination="https://openai.com/api-data-privacy"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{helpPrivacyText}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loadingStatus === RequestStatus.SUCCESSFUL) {
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: xpertSettings?.enabled !== undefined,
|
||||
checked: xpertSettings?.enabled?.toString() || 'true',
|
||||
...initialValues,
|
||||
}}
|
||||
validationSchema={
|
||||
Yup.object()
|
||||
.shape({
|
||||
enabled: Yup.boolean(),
|
||||
checked: Yup.string().oneOf(['true', 'false']),
|
||||
...validationSchema,
|
||||
})
|
||||
}
|
||||
onSubmit={handleFormSubmit}
|
||||
enableReinitialize={enableReinitialize}
|
||||
>
|
||||
{(formikProps) => (
|
||||
<Form onSubmit={handleFormikSubmit(formikProps)}>
|
||||
<SettingsModalBase
|
||||
title={title}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
variant={modalVariant}
|
||||
isMobile={isMobile}
|
||||
isFullscreenOnMobile
|
||||
intl={intl}
|
||||
footer={(
|
||||
<StatefulButton
|
||||
labels={{
|
||||
default: intl.formatMessage(messages.save),
|
||||
pending: intl.formatMessage(messages.saving),
|
||||
complete: intl.formatMessage(messages.saved),
|
||||
}}
|
||||
state={submitButtonState}
|
||||
onClick={handleFormikSubmit(formikProps)}
|
||||
disabled={!formikProps.dirty}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{saveError && (
|
||||
<Alert variant="danger" icon={Info} ref={alertRef}>
|
||||
<Alert.Heading>
|
||||
{formikProps.errors.enabled?.title || intl.formatMessage(messages.errorSavingTitle)}
|
||||
</Alert.Heading>
|
||||
{formikProps.errors.enabled?.message || intl.formatMessage(messages.errorSavingMessage)}
|
||||
</Alert>
|
||||
)}
|
||||
<FormSwitchGroup
|
||||
id={`enable-${appId}-toggle`}
|
||||
name="enabled"
|
||||
onChange={formikProps.handleChange}
|
||||
onBlur={formikProps.handleBlur}
|
||||
checked={formikProps.values.enabled}
|
||||
label={(
|
||||
<div className="d-flex align-items-center">
|
||||
{enableAppLabel}
|
||||
{formikProps.values.enabled && (
|
||||
<Badge className="ml-2" variant="success" data-testid="enable-badge">
|
||||
{intl.formatMessage(messages.enabled)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
helpText={(
|
||||
<div>
|
||||
<p>{enableAppHelp}</p>
|
||||
{helpPrivacyLink}
|
||||
{learnMoreLink}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{(formikProps.values.enabled || configureBeforeEnable) && (
|
||||
<Form.RadioSet
|
||||
name="checked"
|
||||
onChange={formikProps.handleChange}
|
||||
onBlur={formikProps.handleBlur}
|
||||
value={formikProps.values.checked}
|
||||
>
|
||||
<Form.Radio
|
||||
className="summary-radio m-2 px-3"
|
||||
data-testid="enable-radio"
|
||||
value="true"
|
||||
>
|
||||
{allUnitsEnabledText}
|
||||
<ResetUnitsButton
|
||||
intl={intl}
|
||||
courseId={courseId}
|
||||
checked={formikProps.values.checked}
|
||||
visible={formikProps.values.checked === 'true'}
|
||||
/>
|
||||
</Form.Radio>
|
||||
<Form.Radio
|
||||
className="summary-radio m-2 px-3"
|
||||
data-testid="disable-radio"
|
||||
value="false"
|
||||
>
|
||||
{noUnitsEnabledText}
|
||||
<ResetUnitsButton
|
||||
intl={intl}
|
||||
courseId={courseId}
|
||||
checked={formikProps.values.checked}
|
||||
visible={formikProps.values.checked === 'false'}
|
||||
/>
|
||||
</Form.Radio>
|
||||
</Form.RadioSet>
|
||||
)}
|
||||
{(formikProps.values.enabled || configureBeforeEnable) && children
|
||||
&& <AppConfigFormDivider marginAdj={{ default: 0, sm: 0 }} />}
|
||||
<AppSettingsForm formikProps={formikProps} showForm={formikProps.values.enabled || configureBeforeEnable}>
|
||||
{children}
|
||||
</AppSettingsForm>
|
||||
</SettingsModalBase>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SettingsModalBase
|
||||
intl={intl}
|
||||
title={title}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
variant={modalVariant}
|
||||
isMobile={isMobile}
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
{loadingStatus === RequestStatus.IN_PROGRESS && <Loading />}
|
||||
{loadingStatus === RequestStatus.FAILED && <ConnectionErrorAlert />}
|
||||
{loadingStatus === RequestStatus.DENIED && <PermissionDeniedAlert />}
|
||||
</SettingsModalBase>
|
||||
);
|
||||
};
|
||||
|
||||
SettingsModal.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
appId: PropTypes.string.isRequired,
|
||||
children: PropTypes.func,
|
||||
onSettingsSave: PropTypes.func,
|
||||
initialValues: PropTypes.shape({}),
|
||||
validationSchema: PropTypes.shape({}),
|
||||
onClose: PropTypes.func.isRequired,
|
||||
enableAppLabel: PropTypes.string.isRequired,
|
||||
enableAppHelp: PropTypes.string.isRequired,
|
||||
learnMoreText: PropTypes.string.isRequired,
|
||||
helpPrivacyText: PropTypes.string.isRequired,
|
||||
allUnitsEnabledText: PropTypes.string.isRequired,
|
||||
noUnitsEnabledText: PropTypes.string.isRequired,
|
||||
configureBeforeEnable: PropTypes.bool,
|
||||
enableReinitialize: PropTypes.bool,
|
||||
};
|
||||
|
||||
SettingsModal.defaultProps = {
|
||||
children: null,
|
||||
onSettingsSave: null,
|
||||
initialValues: {},
|
||||
validationSchema: {},
|
||||
configureBeforeEnable: false,
|
||||
enableReinitialize: false,
|
||||
};
|
||||
|
||||
export default injectIntl(SettingsModal);
|
||||
@@ -0,0 +1,45 @@
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@openedx/paragon/scss/core/utilities-only";
|
||||
|
||||
.summary-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border-width: $border-width;
|
||||
border-color: $border-color;
|
||||
border-radius: $border-radius;
|
||||
border-style: solid;
|
||||
|
||||
&:has(input:checked) {
|
||||
border-width: 3px;
|
||||
border-color: theme-color("primary");
|
||||
}
|
||||
|
||||
> div {
|
||||
flex: 1;
|
||||
|
||||
> label {
|
||||
min-height: 80px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reset-units-button {
|
||||
color: $link-color;
|
||||
border-width: $border-width;
|
||||
border-color: $border-color;
|
||||
border-radius: $border-radius;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.reset-tooltip {
|
||||
.arrow::before {
|
||||
border-right-color: #00262B;
|
||||
}
|
||||
|
||||
.tooltip-inner {
|
||||
background-color: #00262B;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
cancel: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
save: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.button.save',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
saving: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.button.saving',
|
||||
defaultMessage: 'Saving',
|
||||
},
|
||||
saved: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.button.saved',
|
||||
defaultMessage: 'Saved',
|
||||
},
|
||||
retry: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.button.retry',
|
||||
defaultMessage: 'Retry',
|
||||
},
|
||||
enabled: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.badge.enabled',
|
||||
defaultMessage: 'Enabled',
|
||||
},
|
||||
disabled: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.badge.disabled',
|
||||
defaultMessage: 'Disabled',
|
||||
},
|
||||
resetAllUnits: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.reset-all-units',
|
||||
defaultMessage: 'Reset all units',
|
||||
},
|
||||
resetAllUnitsTooltipChecked: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.reset-all-units-tooltip.checked',
|
||||
defaultMessage: 'Immediately reset any unit-level changes and checked "Enable summaries" on all units.',
|
||||
},
|
||||
resetAllUnitsTooltipUnchecked: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.reset-all-units-tooltip.unchecked',
|
||||
defaultMessage: 'Immediately reset any unit-level changes and unchecked "Enable summaries" on all units.',
|
||||
},
|
||||
reset: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.reset',
|
||||
defaultMessage: 'Reset',
|
||||
},
|
||||
errorSavingTitle: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.save-error.title',
|
||||
defaultMessage: 'We couldn\'t apply your changes.',
|
||||
},
|
||||
errorSavingMessage: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.save-error.message',
|
||||
defaultMessage: 'Please check your entries and try again.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -4,7 +4,7 @@
|
||||
<title>Course Authoring | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="<%= process.env.FAVICON_URL %>" type="image/x-icon" />
|
||||
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base",
|
||||
"schedule:daily",
|
||||
"schedule:weekly",
|
||||
":rebaseStalePrs",
|
||||
":semanticCommits"
|
||||
":semanticCommits",
|
||||
":dependencyDashboard"
|
||||
],
|
||||
"timezone": "America/New_York",
|
||||
"patch": {
|
||||
"automerge": true
|
||||
"automerge": false
|
||||
},
|
||||
"rebaseStalePrs": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["@edx"],
|
||||
"extends": [
|
||||
"schedule:daily"
|
||||
],
|
||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@edx/frontend-lib-content-components"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": false,
|
||||
"schedule": [
|
||||
"after 1am",
|
||||
"before 11pm"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,20 +1,44 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import Header from './studio-header/Header';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import Header from './header';
|
||||
import { fetchCourseDetail } from './data/thunks';
|
||||
import { useModel } from './generic/model-store';
|
||||
import NotFoundAlert from './generic/NotFoundAlert';
|
||||
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
||||
import { getCourseAppsApiStatus, getLoadingStatus } from './pages-and-resources/data/selectors';
|
||||
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
|
||||
import { RequestStatus } from './data/constants';
|
||||
import Loading from './generic/Loading';
|
||||
|
||||
export default function CourseAuthoringPage({ courseId, children }) {
|
||||
const AppHeader = ({
|
||||
courseNumber, courseOrg, courseTitle, courseId,
|
||||
}) => (
|
||||
<Header
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
);
|
||||
|
||||
AppHeader.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
courseNumber: PropTypes.string,
|
||||
courseOrg: PropTypes.string,
|
||||
courseTitle: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
AppHeader.defaultProps = {
|
||||
courseNumber: null,
|
||||
courseOrg: null,
|
||||
};
|
||||
|
||||
const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,41 +51,42 @@ export default function CourseAuthoringPage({ courseId, children }) {
|
||||
const courseOrg = courseDetail ? courseDetail.org : null;
|
||||
const courseTitle = courseDetail ? courseDetail.name : courseId;
|
||||
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
|
||||
const inProgress = useSelector(getLoadingStatus) === RequestStatus.IN_PROGRESS;
|
||||
const courseDetailStatus = useSelector(state => state.courseDetail.status);
|
||||
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS;
|
||||
const { pathname } = useLocation();
|
||||
const isEditor = pathname.includes('/editor');
|
||||
|
||||
if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) {
|
||||
return (
|
||||
<NotFoundAlert />
|
||||
);
|
||||
}
|
||||
if (courseAppsApiStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
|
||||
const AppHeader = () => (
|
||||
<Header
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
);
|
||||
|
||||
const AppFooter = () => (
|
||||
<div className="mt-6">
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-light-200">
|
||||
{/* While V2 Editors are tempoarily served from thier own pages
|
||||
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
|
||||
{/* While V2 Editors are temporarily served from their own pages
|
||||
using url pattern containing /editor/,
|
||||
we shouldn't have the header and footer on these pages.
|
||||
This functionality will be removed in TNL-9591 */}
|
||||
{inProgress ? !pathname.includes('/editor/') && <Loading /> : <AppHeader />}
|
||||
{inProgress ? !isEditor && <Loading />
|
||||
: (!isEditor && (
|
||||
<AppHeader
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{children}
|
||||
{!inProgress && <AppFooter />}
|
||||
{!inProgress && !isEditor && <StudioFooter />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
CourseAuthoringPage.propTypes = {
|
||||
children: PropTypes.node,
|
||||
@@ -71,3 +96,5 @@ CourseAuthoringPage.propTypes = {
|
||||
CourseAuthoringPage.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
export default CourseAuthoringPage;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { queryByTestId, render } from '@testing-library/react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
@@ -12,6 +12,7 @@ import CourseAuthoringPage from './CourseAuthoringPage';
|
||||
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
||||
import { executeThunk } from './utils';
|
||||
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
||||
import { fetchCourseDetail } from './data/thunks';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
let mockPathname = '/evilguy/';
|
||||
@@ -23,50 +24,18 @@ jest.mock('react-router-dom', () => ({
|
||||
}));
|
||||
let axiosMock;
|
||||
let store;
|
||||
let container;
|
||||
function renderComponent() {
|
||||
const wrapper = render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</CourseAuthoringPage>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
const mockStore = async () => {
|
||||
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const courseAppsApiUrl = `${apiBaseUrl}/api/course_apps/v1/apps`;
|
||||
axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(403, {
|
||||
response: { status: 403 },
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
};
|
||||
describe('DiscussionsSettings', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
test('renders permission error in case of 403', async () => {
|
||||
await mockStore();
|
||||
renderComponent();
|
||||
expect(queryByTestId(container, 'permissionDeniedAlert')).toBeInTheDocument();
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
describe('Editor Pages Load no header', () => {
|
||||
@@ -78,18 +47,6 @@ describe('Editor Pages Load no header', () => {
|
||||
});
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
};
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
test('renders no loading wheel on editor pages', async () => {
|
||||
mockPathname = '/editor/';
|
||||
await mockStoreSuccess();
|
||||
@@ -121,3 +78,56 @@ describe('Editor Pages Load no header', () => {
|
||||
expect(wrapper.queryByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course authoring page', () => {
|
||||
const lmsApiBaseUrl = getConfig().LMS_BASE_URL;
|
||||
const courseDetailApiUrl = `${lmsApiBaseUrl}/api/courses/v1/courses`;
|
||||
const mockStoreNotFound = async () => {
|
||||
axiosMock.onGet(
|
||||
`${courseDetailApiUrl}/${courseId}?username=abc123`,
|
||||
).reply(404, {
|
||||
response: { status: 404 },
|
||||
});
|
||||
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
|
||||
};
|
||||
const mockStoreError = async () => {
|
||||
axiosMock.onGet(
|
||||
`${courseDetailApiUrl}/${courseId}?username=abc123`,
|
||||
).reply(500, {
|
||||
response: { status: 500 },
|
||||
});
|
||||
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
|
||||
};
|
||||
test('renders not found page on non-existent course key', async () => {
|
||||
await mockStoreNotFound();
|
||||
const wrapper = render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
|
||||
});
|
||||
test('does not render not found page on other kinds of error', async () => {
|
||||
await mockStoreError();
|
||||
// Currently, loading errors are not handled, so we wait for the child
|
||||
// content to be rendered -which happens when request status is no longer
|
||||
// IN_PROGRESS but also not NOT_FOUND or DENIED- then check that the not
|
||||
// found alert is not present.
|
||||
const contentTestId = 'courseAuthoringPageContent';
|
||||
const wrapper = render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<div data-testid={contentTestId} />
|
||||
</CourseAuthoringPage>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
|
||||
expect(wrapper.queryByTestId('notFoundAlert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Switch, useRouteMatch } from 'react-router';
|
||||
import { PageRoute } from '@edx/frontend-platform/react';
|
||||
import {
|
||||
Navigate, Routes, Route, useParams,
|
||||
} from 'react-router-dom';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { PageWrap } from '@edx/frontend-platform/react';
|
||||
import CourseAuthoringPage from './CourseAuthoringPage';
|
||||
import { PagesAndResources } from './pages-and-resources';
|
||||
import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings';
|
||||
import EditorContainer from './editors/EditorContainer';
|
||||
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
|
||||
import CustomPages from './custom-pages';
|
||||
import { FilesPage, VideosPage } from './files-and-videos';
|
||||
import { AdvancedSettings } from './advanced-settings';
|
||||
import { CourseOutline } from './course-outline';
|
||||
import ScheduleAndDetails from './schedule-and-details';
|
||||
import { GradingSettings } from './grading-settings';
|
||||
import CourseTeam from './course-team/CourseTeam';
|
||||
import { CourseUpdates } from './course-updates';
|
||||
import { CourseUnit } from './course-unit';
|
||||
import CourseExportPage from './export-page/CourseExportPage';
|
||||
import CourseImportPage from './import-page/CourseImportPage';
|
||||
import { DECODED_ROUTES } from './constants';
|
||||
import CourseChecklist from './course-checklist';
|
||||
|
||||
/**
|
||||
* As of this writing, these routes are mounted at a path prefixed with the following:
|
||||
@@ -23,30 +38,86 @@ import EditorContainer from './editors/EditorContainer';
|
||||
* can move the Header/Footer rendering to this component and likely pull the course detail loading
|
||||
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
|
||||
*/
|
||||
export default function CourseAuthoringRoutes({ courseId }) {
|
||||
const { path } = useRouteMatch();
|
||||
const CourseAuthoringRoutes = () => {
|
||||
const { courseId } = useParams();
|
||||
|
||||
return (
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<Switch>
|
||||
<PageRoute path={`${path}/pages-and-resources`}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/proctored-exam-settings`}>
|
||||
<ProctoredExamSettings courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/editor/:blockType/:blockId`}>
|
||||
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
|
||||
&& (
|
||||
<EditorContainer
|
||||
courseId={courseId}
|
||||
/>
|
||||
)}
|
||||
</PageRoute>
|
||||
</Switch>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={<PageWrap><CourseOutline courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="course_info"
|
||||
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="assets"
|
||||
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="videos"
|
||||
element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? <PageWrap><VideosPage courseId={courseId} /></PageWrap> : null}
|
||||
/>
|
||||
<Route
|
||||
path="pages-and-resources/*"
|
||||
element={<PageWrap><PagesAndResources courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="proctored-exam-settings"
|
||||
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
|
||||
/>
|
||||
<Route
|
||||
path="custom-pages/*"
|
||||
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
path="editor/course-videos/:blockId"
|
||||
element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? <PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap> : null}
|
||||
/>
|
||||
<Route
|
||||
path="editor/:blockType/:blockId?"
|
||||
element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? <PageWrap><EditorContainer courseId={courseId} /></PageWrap> : null}
|
||||
/>
|
||||
<Route
|
||||
path="settings/details"
|
||||
element={<PageWrap><ScheduleAndDetails courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="settings/grading"
|
||||
element={<PageWrap><GradingSettings courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="course_team"
|
||||
element={<PageWrap><CourseTeam courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="settings/advanced"
|
||||
element={<PageWrap><AdvancedSettings courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="import"
|
||||
element={<PageWrap><CourseImportPage courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="export"
|
||||
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="checklists"
|
||||
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
</Routes>
|
||||
</CourseAuthoringPage>
|
||||
);
|
||||
}
|
||||
|
||||
CourseAuthoringRoutes.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CourseAuthoringRoutes;
|
||||
|
||||
116
src/CourseAuthoringRoutes.test.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
|
||||
import initializeStore from './store';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const pagesAndResourcesMockText = 'Pages And Resources';
|
||||
const editorContainerMockText = 'Editor Container';
|
||||
const videoSelectorContainerMockText = 'Video Selector Container';
|
||||
const customPagesMockText = 'Custom Pages';
|
||||
let store;
|
||||
const mockComponentFn = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
courseId,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the TinyMceWidget from frontend-lib-content-components
|
||||
jest.mock('@edx/frontend-lib-content-components', () => ({
|
||||
TinyMceWidget: () => <div>Widget</div>,
|
||||
Footer: () => <div>Footer</div>,
|
||||
prepareEditorRef: jest.fn(() => ({
|
||||
refReady: true,
|
||||
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('./pages-and-resources/PagesAndResources', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return pagesAndResourcesMockText;
|
||||
});
|
||||
jest.mock('./editors/EditorContainer', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return editorContainerMockText;
|
||||
});
|
||||
jest.mock('./selectors/VideoSelectorContainer', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return videoSelectorContainerMockText;
|
||||
});
|
||||
jest.mock('./custom-pages/CustomPages', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return customPagesMockText;
|
||||
});
|
||||
|
||||
describe('<CourseAuthoringRoutes>', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
fit('renders the PagesAndResources component when the pages and resources route is active', () => {
|
||||
render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/pages-and-resources']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the EditorContainer component when the course editor route is active', () => {
|
||||
render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/editor/video/block-id']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the VideoSelectorContainer component when the course videos route is active', () => {
|
||||
render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/editor/course-videos/block-id']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const AccessibilityBody = ({
|
||||
communityAccessibilityLink,
|
||||
email,
|
||||
}) => (
|
||||
<div className="mt-5">
|
||||
<header>
|
||||
<h2 className="mb-4 pb-1">
|
||||
<FormattedMessage {...messages.a11yBodyPageHeader} />
|
||||
</h2>
|
||||
</header>
|
||||
<Stack gap={2.5}>
|
||||
<div className="small">
|
||||
<FormattedMessage
|
||||
{...messages.a11yBodyIntroGraph}
|
||||
values={{
|
||||
communityAccessibilityLink: (
|
||||
<Hyperlink
|
||||
destination={communityAccessibilityLink}
|
||||
data-testid="accessibility-page-link"
|
||||
>
|
||||
Website Accessibility Policy
|
||||
</Hyperlink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="small">
|
||||
<FormattedMessage {...messages.a11yBodyStepsHeader} />
|
||||
</div>
|
||||
<ol className="small m-0">
|
||||
<li>
|
||||
<FormattedMessage
|
||||
{...messages.a11yBodyEmailHeading}
|
||||
values={{
|
||||
emailElement: (
|
||||
<MailtoLink
|
||||
to={email}
|
||||
data-testid="email-element"
|
||||
>
|
||||
{email}
|
||||
</MailtoLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<ul>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyNameEmail} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyInstitution} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyBarrier} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyTimeConstraints} />
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyReceipt} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyExtraInfo} />
|
||||
</li>
|
||||
</ol>
|
||||
<div className="small">
|
||||
<FormattedMessage
|
||||
{...messages.a11yBodyA11yFeedback}
|
||||
values={{
|
||||
emailElement: (
|
||||
<MailtoLink
|
||||
to={email}
|
||||
data-testid="email-element"
|
||||
>
|
||||
{email}
|
||||
</MailtoLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
|
||||
AccessibilityBody.propTypes = {
|
||||
communityAccessibilityLink: PropTypes.string.isRequired,
|
||||
email: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccessibilityBody);
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import initializeStore from '../../store';
|
||||
|
||||
import AccessibilityBody from './index';
|
||||
|
||||
let store;
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AccessibilityBody
|
||||
communityAccessibilityLink="http://example.com"
|
||||
email="example@example.com"
|
||||
/>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AccessibilityBody />', () => {
|
||||
describe('renders', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore({});
|
||||
});
|
||||
it('contains links', () => {
|
||||
renderComponent();
|
||||
expect(screen.getAllByTestId('email-element')).toHaveLength(2);
|
||||
expect(screen.getAllByTestId('accessibility-page-link')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
3
src/accessibility-page/AccessibilityBody/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import AccessibilityBody from './AccessibilityBody';
|
||||
|
||||
export default AccessibilityBody;
|
||||
111
src/accessibility-page/AccessibilityBody/messages.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
a11yBodyPolicyLink: {
|
||||
id: 'a11yBodyPolicyLink',
|
||||
defaultMessage: 'Website Accessibility Policy',
|
||||
description: 'Title for link to full accessibility policy.',
|
||||
},
|
||||
a11yBodyPageHeader: {
|
||||
id: 'a11yBodyPageHeader',
|
||||
defaultMessage: 'Individualized Accessibility Process for Course Creators',
|
||||
description: 'Heading for studio\'s accessibility policy page.',
|
||||
},
|
||||
a11yBodyIntroGraph: {
|
||||
id: 'a11yBodyIntroGraph',
|
||||
defaultMessage: `At edX, we seek to understand and respect the unique needs and perspectives of the edX global community.
|
||||
We value every course team and are committed to expanding access to all, including course team creators and authors with
|
||||
disabilities. To that end, we have adopted a {communityAccessibilityLink} and this process to allow course team creators
|
||||
and authors to request assistance if they are unable to develop and post content on our platform via Studio because of their
|
||||
disabilities.`,
|
||||
description: 'Introductory paragraph outlining why we care about accessibility, and what we\'re doing about it.',
|
||||
},
|
||||
a11yBodyStepsHeader: {
|
||||
id: 'a11yBodyStepsHeader',
|
||||
defaultMessage: 'Course team creators and authors needing such assistance should take the following steps:',
|
||||
description: 'Heading for list of steps authors can take for accessibility requests.',
|
||||
},
|
||||
a11yBodyEdxResponse: {
|
||||
id: 'a11yBodyEdxResponse',
|
||||
defaultMessage: `'We will communicate with you about your preferences and needs in determining the appropriate solution, although
|
||||
the ultimate decision will be ours, provided that the solution is effective and timely. The factors we will consider in choosing
|
||||
an accessibility solution are: effectiveness; timeliness (relative to your deadlines); ease of implementation; and ease of use for
|
||||
you. We will notify you of the decision and explain the basis for our decision within 10 business days of discussing with you.`,
|
||||
description: 'Paragraph outlining how we will select an accessibility solution.',
|
||||
},
|
||||
a11yBodyEdxFollowUp: {
|
||||
id: 'a11yBodyEdxFollowUp',
|
||||
defaultMessage: `Thereafter, we will communicate with you on a weekly basis regarding our evaluation, decision, and progress in
|
||||
implementing the accessibility solution. We will notify you when implementation of your accessibility solution is complete and
|
||||
will follow-up with you as may be necessary to see if the solution was effective.`,
|
||||
description: 'Paragraph outlining how we will follow-up with you during and after implementing an accessibility solution.',
|
||||
},
|
||||
a11yBodyOngoingSupport: {
|
||||
id: 'a11yBodyOngoingSupport',
|
||||
defaultMessage: 'EdX will provide ongoing technical support as needed and will address any additional issues that arise after the initial course creation.',
|
||||
description: 'A statement of ongoing support.',
|
||||
},
|
||||
a11yBodyA11yFeedback: {
|
||||
id: 'a11yBodyA11yFeedback',
|
||||
defaultMessage: 'Please direct any questions or suggestions on how to improve the accessibility of Studio to {emailElement} or use the form below. We welcome your feedback.',
|
||||
description: 'Contact information heading for those with accessibility issues or suggestions.',
|
||||
},
|
||||
a11yBodyEmailHeading: {
|
||||
id: 'a11yBodyEmailHeading',
|
||||
defaultMessage: 'Send an email to {emailElement} with the following information:',
|
||||
description: 'Heading for list of information required when you email us.',
|
||||
},
|
||||
a11yBodyNameEmail: {
|
||||
id: 'a11yBodyNameEmail',
|
||||
defaultMessage: 'your name and email address;',
|
||||
description: 'Your contact information.',
|
||||
},
|
||||
a11yBodyInstitution: {
|
||||
id: 'a11yBodyInstitution',
|
||||
defaultMessage: 'the edX member institution that you are affiliated with;',
|
||||
description: 'edX affiliate information.',
|
||||
},
|
||||
a11yBodyBarrier: {
|
||||
id: 'a11yBodyBarrier',
|
||||
defaultMessage: 'a brief description of the challenge or barrier to access that you are experiencing; and',
|
||||
description: 'Accessibility problem information.',
|
||||
},
|
||||
a11yBodyTimeConstraints: {
|
||||
id: 'a11yBodyTimeConstraints',
|
||||
defaultMessage: 'how soon you need access and for how long (e.g., a planned course start date or in connection with a course-related deadline such as a final essay).',
|
||||
description: 'Time contstraint information.',
|
||||
},
|
||||
a11yBodyReceipt: {
|
||||
id: 'a11yBodyReceipt',
|
||||
defaultMessage: 'The edX Support Team will respond to confirm receipt and forward your request to the edX Partner Manager for your institution and the edX Website Accessibility Specialist.',
|
||||
description: 'Paragraph outlining what steps edX will take immediately.',
|
||||
},
|
||||
a11yBodyExtraInfo: {
|
||||
id: 'a11yBodyExtraInfo',
|
||||
defaultMessage: `With guidance from the Website Accessibility Specialist, edX will contact you to discuss your request and gather
|
||||
additional information from you about your preferences and needs, to determine if there's a workable solution that edX is able to support.`,
|
||||
description: 'Paragraph outlining how and when edX will reach out to you.',
|
||||
},
|
||||
a11yBodyFixesListHeader: {
|
||||
id: 'a11yBodyFixesListHeader',
|
||||
defaultMessage: 'EdX will assist you promptly and thoroughly so that you are able to create content on the CMS within your time constraints. Such efforts may include, but are not limited to:',
|
||||
description: 'Heading for list of ways we might be able to assist.',
|
||||
},
|
||||
a11yBodyThirdParty: {
|
||||
id: 'a11yBodyThirdParty',
|
||||
defaultMessage: 'Purchasing a third-party tool or software for use on an individual basis to assist your use of Studio;',
|
||||
description: 'Buy third-party software.',
|
||||
},
|
||||
a11yBodyContractor: {
|
||||
id: 'a11yBodyContractor',
|
||||
defaultMessage: 'Engaging a trained independent contractor to provide real-time visual, verbal and physical assistance; or',
|
||||
description: 'Hire a contractor.',
|
||||
},
|
||||
a11yBodyCodeFix: {
|
||||
id: 'a11yBodyCodeFix',
|
||||
defaultMessage: 'Developing new code to implement a technical fix.',
|
||||
description: 'Make a technical fix.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
146
src/accessibility-page/AccessibilityForm/AccessibilityForm.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
injectIntl, FormattedMessage, intlShape, FormattedDate, FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Alert, Form, Stack, StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { STATEFUL_BUTTON_STATES } from '../../constants';
|
||||
import submitAccessibilityForm from '../data/thunks';
|
||||
import useAccessibility from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
const AccessibilityForm = ({
|
||||
accessibilityEmail,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const {
|
||||
errors,
|
||||
values,
|
||||
isFormFilled,
|
||||
dispatch,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
hasErrorField,
|
||||
savingStatus,
|
||||
} = useAccessibility({ name: '', email: '', message: '' }, intl);
|
||||
|
||||
const formFields = [
|
||||
{
|
||||
label: intl.formatMessage(messages.accessibilityPolicyFormEmailLabel),
|
||||
name: 'email',
|
||||
value: values.email,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(messages.accessibilityPolicyFormNameLabel),
|
||||
name: 'name',
|
||||
value: values.name,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(messages.accessibilityPolicyFormMessageLabel),
|
||||
name: 'message',
|
||||
value: values.message,
|
||||
},
|
||||
];
|
||||
|
||||
const createButtonState = {
|
||||
labels: {
|
||||
default: intl.formatMessage(messages.accessibilityPolicyFormSubmitLabel),
|
||||
pending: intl.formatMessage(messages.accessibilityPolicyFormSubmittingFeedbackLabel),
|
||||
},
|
||||
disabledStates: [STATEFUL_BUTTON_STATES.pending],
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
dispatch(submitAccessibilityForm(values));
|
||||
};
|
||||
|
||||
const start = new Date('Mon Jan 29 2018 13:00:00 GMT (UTC)');
|
||||
const end = new Date('Fri Feb 2 2018 21:00:00 GMT (UTC)');
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="my-4">
|
||||
<FormattedMessage {...messages.accessibilityPolicyFormHeader} />
|
||||
</h2>
|
||||
{savingStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Alert variant="success">
|
||||
<Stack gap={2}>
|
||||
<div className="mb-2">
|
||||
<FormattedMessage {...messages.accessibilityPolicyFormSuccess} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
{...messages.accessibilityPolicyFormSuccessDetails}
|
||||
values={{
|
||||
day_start: (<FormattedDate value={start} weekday="long" />),
|
||||
time_start: (<FormattedTime value={start} timeZoneName="short" />),
|
||||
day_end: (<FormattedDate value={end} weekday="long" />),
|
||||
time_end: (<FormattedTime value={end} timeZoneName="short" />),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{savingStatus === RequestStatus.FAILED && (
|
||||
<Alert variant="danger">
|
||||
<div data-testid="rate-limit-alert">
|
||||
<FormattedMessage
|
||||
{...messages.accessibilityPolicyFormErrorHighVolume}
|
||||
values={{
|
||||
emailLink: <a href={`mailto:${accessibilityEmail}`}>{accessibilityEmail}</a>,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
<Form>
|
||||
{formFields.map((field) => (
|
||||
<Form.Group size="sm" key={field.label}>
|
||||
<Form.Control
|
||||
value={field.value}
|
||||
name={field.name}
|
||||
isInvalid={hasErrorField(field.name)}
|
||||
type={field.name === 'email' ? 'email' : null}
|
||||
as={field.name === 'message' ? 'textarea' : 'input'}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
floatingLabel={field.label}
|
||||
/>
|
||||
{hasErrorField(field.name) && (
|
||||
<Form.Control.Feedback type="invalid" data-testid={`error-feedback-${field.name}`}>
|
||||
{errors[field.name]}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
))}
|
||||
</Form>
|
||||
<ActionRow>
|
||||
<StatefulButton
|
||||
key="save-button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!isFormFilled}
|
||||
state={
|
||||
savingStatus === RequestStatus.IN_PROGRESS
|
||||
? STATEFUL_BUTTON_STATES.pending
|
||||
: STATEFUL_BUTTON_STATES.default
|
||||
}
|
||||
{...createButtonState}
|
||||
/>
|
||||
</ActionRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AccessibilityForm.propTypes = {
|
||||
accessibilityEmail: PropTypes.string.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccessibilityForm);
|
||||