Compare commits
287 Commits
open-relea
...
CourseRole
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2bfb1fb7b | ||
|
|
c754a5e519 | ||
|
|
1e9146a5b9 | ||
|
|
a518fada29 | ||
|
|
69d9ea318e | ||
|
|
e74e1ff5aa | ||
|
|
1137dae97a | ||
|
|
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 |
13
.env
@@ -16,16 +16,27 @@ LOGO_URL=''
|
|||||||
LOGO_WHITE_URL=''
|
LOGO_WHITE_URL=''
|
||||||
LOGOUT_URL=null
|
LOGOUT_URL=null
|
||||||
MARKETING_SITE_BASE_URL=''
|
MARKETING_SITE_BASE_URL=''
|
||||||
|
TERMS_OF_SERVICE_URL=''
|
||||||
|
PRIVACY_POLICY_URL=''
|
||||||
ORDER_HISTORY_URL=''
|
ORDER_HISTORY_URL=''
|
||||||
PUBLISHER_BASE_URL=''
|
PUBLISHER_BASE_URL=''
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||||
SEGMENT_KEY=''
|
SEGMENT_KEY=''
|
||||||
SITE_NAME=''
|
SITE_NAME=''
|
||||||
|
STUDIO_SHORT_NAME='Studio'
|
||||||
SUPPORT_EMAIL=''
|
SUPPORT_EMAIL=''
|
||||||
SUPPORT_URL=''
|
SUPPORT_URL=''
|
||||||
USER_INFO_COOKIE_NAME=''
|
USER_INFO_COOKIE_NAME=''
|
||||||
|
ENABLE_ACCESSIBILITY_PAGE=false
|
||||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||||
ENABLE_TEAM_TYPE_SETTING=false
|
ENABLE_TEAM_TYPE_SETTING=false
|
||||||
ENABLE_NEW_EDITOR_PAGES=true
|
ENABLE_NEW_EDITOR_PAGES=true
|
||||||
BBB_LEARN_MORE_URL=''
|
ENABLE_UNIT_PAGE=false
|
||||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=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=''
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
NODE_ENV='development'
|
NODE_ENV='development'
|
||||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||||
BASE_URL='localhost:2001'
|
BASE_URL='http://localhost:2001'
|
||||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||||
DISCOVERY_API_BASE_URL=
|
DISCOVERY_API_BASE_URL=
|
||||||
@@ -16,18 +16,29 @@ LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
|||||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||||
LOGOUT_URL='http://localhost:18000/logout'
|
LOGOUT_URL='http://localhost:18000/logout'
|
||||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||||
|
TERMS_OF_SERVICE_URL=
|
||||||
|
PRIVACY_POLICY_URL=
|
||||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||||
PORT=2001
|
PORT=2001
|
||||||
PUBLISHER_BASE_URL=
|
PUBLISHER_BASE_URL=
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||||
SEGMENT_KEY=null
|
SEGMENT_KEY=null
|
||||||
SITE_NAME='edX'
|
SITE_NAME='Your Plaform Name Here'
|
||||||
STUDIO_BASE_URL='http://localhost:18010'
|
STUDIO_BASE_URL='http://localhost:18010'
|
||||||
SUPPORT_EMAIL='support@example.com'
|
STUDIO_SHORT_NAME='Studio'
|
||||||
|
SUPPORT_EMAIL=
|
||||||
SUPPORT_URL='https://support.edx.org'
|
SUPPORT_URL='https://support.edx.org'
|
||||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||||
|
ENABLE_ACCESSIBILITY_PAGE=false
|
||||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||||
ENABLE_TEAM_TYPE_SETTING=false
|
ENABLE_TEAM_TYPE_SETTING=false
|
||||||
ENABLE_NEW_EDITOR_PAGES=true
|
ENABLE_NEW_EDITOR_PAGES=true
|
||||||
BBB_LEARN_MORE_URL=''
|
ENABLE_UNIT_PAGE=false
|
||||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=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'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||||
BASE_URL='localhost:2001'
|
BASE_URL='http://localhost:2001'
|
||||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||||
@@ -22,11 +22,15 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
|||||||
SEGMENT_KEY=null
|
SEGMENT_KEY=null
|
||||||
SITE_NAME='edX'
|
SITE_NAME='edX'
|
||||||
STUDIO_BASE_URL='http://localhost:18010'
|
STUDIO_BASE_URL='http://localhost:18010'
|
||||||
|
STUDIO_SHORT_NAME='Studio'
|
||||||
SUPPORT_EMAIL='support@example.com'
|
SUPPORT_EMAIL='support@example.com'
|
||||||
SUPPORT_URL='https://support.edx.org'
|
SUPPORT_URL='https://support.edx.org'
|
||||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||||
ENABLE_TEAM_TYPE_SETTING=false
|
ENABLE_TEAM_TYPE_SETTING=false
|
||||||
ENABLE_NEW_EDITOR_PAGES=true
|
ENABLE_NEW_EDITOR_PAGES=true
|
||||||
BBB_LEARN_MORE_URL=''
|
ENABLE_UNIT_PAGE=true
|
||||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
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"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
const { createConfig } = require('@edx/frontend-build');
|
const { createConfig } = require('@edx/frontend-build');
|
||||||
|
|
||||||
module.exports = createConfig(
|
module.exports = createConfig(
|
||||||
'eslint',
|
'eslint',
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
'jsx-a11y/label-has-associated-control': [2, {
|
'jsx-a11y/label-has-associated-control': [2, {
|
||||||
@@ -10,7 +10,7 @@ module.exports = createConfig(
|
|||||||
}],
|
}],
|
||||||
'template-curly-spacing': 'off',
|
'template-curly-spacing': 'off',
|
||||||
'react-hooks/exhaustive-deps': 'off',
|
'react-hooks/exhaustive-deps': 'off',
|
||||||
indent: 'off',
|
indent: ['error', 2],
|
||||||
'no-restricted-exports': 'off',
|
'no-restricted-exports': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -20,3 +20,6 @@ temp/babel-plugin-react-intl
|
|||||||
/temp
|
/temp
|
||||||
/.vscode
|
/.vscode
|
||||||
/module.config.js
|
/module.config.js
|
||||||
|
|
||||||
|
# Local environment overrides
|
||||||
|
.env.private
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Makefile
@@ -1,20 +1,21 @@
|
|||||||
transifex_resource = frontend-app-course-authoring
|
transifex_resource = frontend-app-course-authoring
|
||||||
export TRANSIFEX_RESOURCE = ${transifex_resource}
|
export TRANSIFEX_RESOURCE = ${transifex_resource}
|
||||||
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA"
|
transifex_langs = "ar,de,de_DE,es_419,fa_IR,fr,fr_CA,hi,it,it_IT,pt,pt_PT,ru,uk,zh_CN"
|
||||||
|
|
||||||
|
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||||
i18n = ./src/i18n
|
i18n = ./src/i18n
|
||||||
transifex_input = $(i18n)/transifex_input.json
|
transifex_input = $(i18n)/transifex_input.json
|
||||||
|
|
||||||
# This directory must match .babelrc .
|
# This directory must match .babelrc .
|
||||||
transifex_temp = ./temp/babel-plugin-react-intl
|
transifex_temp = ./temp/babel-plugin-formatjs
|
||||||
|
|
||||||
precommit:
|
precommit:
|
||||||
npm run lint
|
npm run lint
|
||||||
npm audit
|
npm audit
|
||||||
|
|
||||||
requirements:
|
requirements:
|
||||||
npm install
|
npm ci
|
||||||
|
|
||||||
i18n.extract:
|
i18n.extract:
|
||||||
# Pulling display strings from .jsx files into .json files...
|
# Pulling display strings from .jsx files into .json files...
|
||||||
@@ -43,9 +44,26 @@ push_translations:
|
|||||||
# Pushing comments to Transifex...
|
# Pushing comments to Transifex...
|
||||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||||
|
|
||||||
|
ifeq ($(OPENEDX_ATLAS_PULL),)
|
||||||
# Pulls translations from Transifex.
|
# Pulls translations from Transifex.
|
||||||
pull_translations:
|
pull_translations:
|
||||||
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
|
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
|
||||||
|
else
|
||||||
|
# Pulls translations using atlas.
|
||||||
|
pull_translations:
|
||||||
|
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
|
||||||
|
endif
|
||||||
|
|
||||||
# This target is used by Travis.
|
# This target is used by Travis.
|
||||||
validate-no-uncommitted-package-lock-changes:
|
validate-no-uncommitted-package-lock-changes:
|
||||||
@@ -57,6 +75,7 @@ validate:
|
|||||||
make validate-no-uncommitted-package-lock-changes
|
make validate-no-uncommitted-package-lock-changes
|
||||||
npm run i18n_extract
|
npm run i18n_extract
|
||||||
npm run lint -- --max-warnings 0
|
npm run lint -- --max-warnings 0
|
||||||
|
npm run types
|
||||||
npm run test
|
npm run test
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
|||||||
274
README.rst
@@ -1,20 +1,70 @@
|
|||||||
|Build Status| |Codecov| |license|
|
|
||||||
|
|
||||||
#############################
|
|
||||||
frontend-app-course-authoring
|
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|
|
||||||
|
|
||||||
************
|
|
||||||
Introduction
|
Purpose
|
||||||
************
|
*******
|
||||||
|
|
||||||
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
|
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.
|
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
|
||||||
|
===================
|
||||||
|
|
||||||
|
|
||||||
|
1. Clone the repo:
|
||||||
|
|
||||||
|
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
|
||||||
|
|
||||||
|
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``
|
||||||
|
|
||||||
|
|
||||||
|
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
|
Features
|
||||||
********
|
********
|
||||||
|
|
||||||
@@ -23,14 +73,12 @@ Feature: Pages and Resources Studio Tab
|
|||||||
|
|
||||||
Enables a "Pages & Resources" menu item in Studio, under the "Content" menu.
|
Enables a "Pages & Resources" menu item in Studio, under the "Content" menu.
|
||||||
|
|
||||||
|
.. image:: ./docs/readme-images/feature-pages-resources.png
|
||||||
|
|
||||||
Requirements
|
Requirements
|
||||||
------------
|
------------
|
||||||
|
|
||||||
The following are external requirements for this feature to function correctly:
|
The following are requirements for this feature to function correctly:
|
||||||
|
|
||||||
* ``edx-platform`` Django settings:
|
|
||||||
|
|
||||||
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
|
|
||||||
|
|
||||||
* ``edx-platform`` Waffle flags:
|
* ``edx-platform`` Waffle flags:
|
||||||
|
|
||||||
@@ -79,15 +127,13 @@ For a particular course, this page allows one to:
|
|||||||
Feature: New React XBlock Editors
|
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.
|
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
|
Requirements
|
||||||
------------
|
------------
|
||||||
|
|
||||||
* ``edx-platform`` Django settings:
|
|
||||||
|
|
||||||
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
|
|
||||||
|
|
||||||
* ``edx-platform`` Waffle flags:
|
* ``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_text_editor``: must be enabled for the new HTML Xblock editor to be used in Studio
|
||||||
@@ -99,7 +145,7 @@ Configuration
|
|||||||
|
|
||||||
In additional to the standard settings, the following local configuration item is required:
|
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
|
* ``ENABLE_NEW_EDITOR_PAGES``: must be enabled in order to actually present the new XBlock editors (on by default)
|
||||||
|
|
||||||
Feature Description
|
Feature Description
|
||||||
-------------------
|
-------------------
|
||||||
@@ -113,12 +159,13 @@ When a corresponding waffle flag is set, upon editing a block in Studio, the vie
|
|||||||
Feature: New Proctoring Exams View
|
Feature: New Proctoring Exams View
|
||||||
==================================
|
==================================
|
||||||
|
|
||||||
|
.. image:: ./docs/readme-images/feature-proctored-exams.png
|
||||||
|
|
||||||
Requirements
|
Requirements
|
||||||
------------
|
------------
|
||||||
|
|
||||||
* ``edx-platform`` Django settings:
|
* ``edx-platform`` Django settings:
|
||||||
|
|
||||||
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
|
|
||||||
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
|
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
|
||||||
|
|
||||||
* ``edx-platform`` Feature flags:
|
* ``edx-platform`` Feature flags:
|
||||||
@@ -144,34 +191,94 @@ In Studio, a new item ("Proctored Exam Settings") is added to "Other Course Sett
|
|||||||
* Select a proctoring provider
|
* Select a proctoring provider
|
||||||
* Enable automatic creation of Zendesk tickets for "suspicious" proctored exam attempts
|
* 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
|
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.
|
`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.
|
||||||
|
|
||||||
Installation and Startup
|
|
||||||
========================
|
|
||||||
|
|
||||||
1. Clone the repo:
|
|
||||||
|
|
||||||
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
|
|
||||||
|
|
||||||
2. Install npm dependencies:
|
|
||||||
|
|
||||||
``cd frontend-app-course-authoring && npm install``
|
|
||||||
|
|
||||||
3. Start the dev server:
|
|
||||||
|
|
||||||
``npm start``
|
|
||||||
|
|
||||||
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
|
|
||||||
|
|
||||||
If your devstack includes the default Demo course, you can visit the following URLs to see content:
|
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>`_
|
||||||
- `Pages and Resources <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources>`_ (work in progress)
|
|
||||||
|
|
||||||
Troubleshooting
|
Troubleshooting
|
||||||
========================
|
========================
|
||||||
@@ -182,7 +289,7 @@ Troubleshooting
|
|||||||
If there is still an error, look for "no package [...] found" in the error message and install missing package via brew.
|
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)
|
(https://github.com/Automattic/node-canvas/issues/1733)
|
||||||
|
|
||||||
*********
|
|
||||||
Deploying
|
Deploying
|
||||||
*********
|
*********
|
||||||
|
|
||||||
@@ -197,3 +304,92 @@ The production build is created with ``npm run build``.
|
|||||||
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
|
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
|
||||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
|
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
|
||||||
:target: @edx/frontend-app-course-authoring
|
: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
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ coverage:
|
|||||||
default:
|
default:
|
||||||
target: auto
|
target: auto
|
||||||
threshold: 0%
|
threshold: 0%
|
||||||
|
ignore:
|
||||||
|
- "src/grading-settings/grading-scale/react-ranger.js"
|
||||||
|
- "src/index.js"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
Background
|
||||||
|
==========
|
||||||
|
|
||||||
|
This is a summary of the technical decisions made for the Roles & Permissions
|
||||||
|
project as we implemented the permissions check system in the ``frontend-app-course-authoring``.
|
||||||
|
|
||||||
|
The ``frontend-app-course-authoring`` was already created when the
|
||||||
|
Permissions project started, so it already had a coding style, store
|
||||||
|
management and its own best practices.
|
||||||
|
We aligned to these requirements.
|
||||||
|
|
||||||
|
Frontend Architecture
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
* `Readme <https://github.com/openedx/frontend-app-course-authoring#readme>`__
|
||||||
|
* Developing locally:
|
||||||
|
https://github.com/openedx/frontend-app-course-authoring#readme
|
||||||
|
* **React.js** application ``version: 17.0.2``
|
||||||
|
* **Redux** store management ``version: 4.0.5``
|
||||||
|
* It uses **Thunk** for adding to Redux the ability of returning
|
||||||
|
functions.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
Local Development & Testing
|
||||||
|
===========================
|
||||||
|
|
||||||
|
Backend
|
||||||
|
~~~~~~~
|
||||||
|
|
||||||
|
The backend endpoints lives in the ``edx-platform`` repo, specifically
|
||||||
|
in this file: ``openedx/core/djangoapps/course_roles/views.py``
|
||||||
|
|
||||||
|
For quickly testing the different permissions and the flag change you
|
||||||
|
can tweak the values directly in the above file.
|
||||||
|
|
||||||
|
* ``UserPermissionsView`` is in charge of returning the permissions, so
|
||||||
|
for sending the permissions you want to check, you could do something
|
||||||
|
like this:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
permissions = {
|
||||||
|
'user_id': user_id,
|
||||||
|
'course_key': str(course_key),
|
||||||
|
#'permissions': sorted(permission.value.name for permission in permissions_set),
|
||||||
|
'permissions': ['the_permissions_being_tested']
|
||||||
|
}
|
||||||
|
return Response(permissions)
|
||||||
|
|
||||||
|
By making this change, the permissions object will be bypassed and
|
||||||
|
send a plain array with the specific permissions being tested.
|
||||||
|
|
||||||
|
|
||||||
|
* ``UserPermissionsFlagView`` is in charge of returning the flag value
|
||||||
|
(boolean), so you can easily turn the boolean like this:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
#payload = {'enabled': use_permission_checks()}
|
||||||
|
payload = {'enabled': true}
|
||||||
|
return Response(payload)
|
||||||
|
|
||||||
|
Flags
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
You’ll need at least 2 flags to start:
|
||||||
|
|
||||||
|
* The basic flag for enabling the backend permissions system: ``course_roles.use_permission_checks``.
|
||||||
|
|
||||||
|
* The flag for enabling the page you want to test, for instance Course Team: ``contentstore.new_studio_mfe.use_new_course_team_page``.
|
||||||
|
|
||||||
|
All flags for enabling pages in the Studio MFE are listed
|
||||||
|
`here <https://2u-internal.atlassian.net/wiki/x/CQCcHQ>`__.
|
||||||
|
|
||||||
|
Flags can be added by:
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
* Enter to ``http://localhost:18000/admin/``.
|
||||||
|
* Log in as an admin.
|
||||||
|
* Go to ``http://localhost:18000/admin/waffle/flag/``.
|
||||||
|
* Click on ``+ADD FLAG`` button at the top right of the page and add
|
||||||
|
the flag you need.
|
||||||
|
|
||||||
|
Testing
|
||||||
|
~~~~~~~
|
||||||
|
|
||||||
|
For unit testing you run the npm script included in the ``package.json``, you can use it plainly for testing all components at once: ``npm run test``.
|
||||||
|
|
||||||
|
Or you can test one file at a time: ``npm run test path-to-file``.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
Permissions Check implementation
|
||||||
|
================================
|
||||||
|
|
||||||
|
For the permissions checks we basically hit 2 endpoints from the
|
||||||
|
``edx-platform`` repo:
|
||||||
|
|
||||||
|
* **Permissions**:
|
||||||
|
``/api/course_roles/v1/user_permissions/?course_id=[course_key]&user_id=[user_id]``
|
||||||
|
|
||||||
|
Which will return this structure:
|
||||||
|
|
||||||
|
.. code-block:: js
|
||||||
|
|
||||||
|
permissions = {
|
||||||
|
'user_id': [user_id],
|
||||||
|
'course_key': [course_key],
|
||||||
|
'permissions': ['permission_1', 'permission_2']
|
||||||
|
}
|
||||||
|
|
||||||
|
* **Permissions enabled** (which returns the boolean flag value): ``/api/course_roles/v1/user_permissions/enabled/``
|
||||||
|
|
||||||
|
The basic scaffolding for *fetching* and *storing* the permissions is located in the ``src/generic/data`` folder:
|
||||||
|
|
||||||
|
* ``api.js``: Exposes the ``getUserPermissions(courseId)`` and ``getUserPermissionsEnabledFlag()`` methods.
|
||||||
|
* ``selectors.js``: Exposes the selectors ``getUserPermissions`` and ``getUserPermissionsEnabled`` to be used by ``useSelector()``.
|
||||||
|
* ``slice.js``: Exposes the ``updateUserPermissions`` and ``updateUserPermissionsEnabled`` methods that will be used by the ``thunks.js`` file for dispatching and storing.
|
||||||
|
* ``thunks.js``: Exposes the ``fetchUserPermissionsQuery(courseId)`` and ``fetchUserPermissionsEnabledFlag()`` methods for fetching.
|
||||||
|
|
||||||
|
In the ``src/generic/hooks.jsx`` we created a custom hook for exposing the ``checkPermission`` method, so that way we can call
|
||||||
|
this method from any page and pass the permission we want to check for the current logged in user.
|
||||||
|
|
||||||
|
In this example on the ``src/course-team/CourseTeam.jsx`` page, we use the hook for checking if the current user has the ``manage_all_users``
|
||||||
|
permission:
|
||||||
|
|
||||||
|
1. First, we import the hook (line 1).
|
||||||
|
|
||||||
|
2. Then we call the ``checkPermission`` method and assign it to a const (line 2).
|
||||||
|
|
||||||
|
3. Finally we use the const for showing or hiding a button (line 8).
|
||||||
|
|
||||||
|
.. code-block:: js
|
||||||
|
|
||||||
|
1. import { useUserPermissions } from '../generic/hooks';
|
||||||
|
2. const hasManageAllUsersPerm = checkPermission('manage_all_users');
|
||||||
|
|
||||||
|
3. <SubHeader
|
||||||
|
4. title={intl.formatMessage(messages.headingTitle)}
|
||||||
|
5. subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||||
|
6. headerActions={(
|
||||||
|
7. isAllowActions ||
|
||||||
|
8. hasManageAllUsersPerm
|
||||||
|
9. ) && (
|
||||||
|
10. <Button
|
||||||
|
11. variant="primary"
|
||||||
|
12. iconBefore={IconAdd}
|
||||||
|
13. size="sm"
|
||||||
|
14. onClick={openForm}
|
||||||
|
15. >
|
||||||
|
16. {intl.formatMessage(messages.addNewMemberButton)}
|
||||||
|
17. </Button>
|
||||||
|
18. )}
|
||||||
|
19. />
|
||||||
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 |
@@ -2,16 +2,17 @@ const { createConfig } = require('@edx/frontend-build');
|
|||||||
|
|
||||||
module.exports = createConfig('jest', {
|
module.exports = createConfig('jest', {
|
||||||
setupFilesAfterEnv: [
|
setupFilesAfterEnv: [
|
||||||
|
'jest-expect-message',
|
||||||
'<rootDir>/src/setupTest.js',
|
'<rootDir>/src/setupTest.js',
|
||||||
],
|
],
|
||||||
coveragePathIgnorePatterns: [
|
coveragePathIgnorePatterns: [
|
||||||
'src/setupTest.js',
|
'src/setupTest.js',
|
||||||
'src/i18n',
|
'src/i18n',
|
||||||
],
|
],
|
||||||
snapshotSerializers: [
|
|
||||||
'enzyme-to-json/serializer',
|
|
||||||
],
|
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^lodash-es$': 'lodash',
|
'^lodash-es$': 'lodash',
|
||||||
},
|
},
|
||||||
|
modulePathIgnorePatterns: [
|
||||||
|
'/src/pages-and-resources/utils.test.jsx',
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
16341
package-lock.json
generated
94
package.json
@@ -11,12 +11,15 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "fedx-scripts webpack",
|
"build": "fedx-scripts webpack",
|
||||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
"i18n_extract": "fedx-scripts formatjs extract",
|
||||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
"stylelint": "stylelint \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
|
||||||
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
|
||||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
"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",
|
"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": {
|
"husky": {
|
||||||
"hooks": {
|
"hooks": {
|
||||||
@@ -33,51 +36,70 @@
|
|||||||
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
|
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@edx/frontend-component-footer": "11.1.1",
|
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||||
"@edx/frontend-lib-content-components": "^1.131.0",
|
"@edx/frontend-component-ai-translations": "^1.4.0",
|
||||||
"@edx/frontend-platform": "2.5.1",
|
"@edx/frontend-component-footer": "^12.3.0",
|
||||||
"@edx/paragon": "^20.38.0",
|
"@edx/frontend-component-header": "^4.7.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "1.2.28",
|
"@edx/frontend-enterprise-hotjar": "^2.0.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "5.11.2",
|
"@edx/frontend-lib-content-components": "^1.178.2",
|
||||||
"@fortawesome/free-regular-svg-icons": "5.11.2",
|
"@edx/frontend-platform": "5.6.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "5.11.2",
|
"@edx/openedx-atlas": "^0.6.0",
|
||||||
"@fortawesome/react-fontawesome": "0.1.9",
|
"@edx/paragon": "^21.5.6",
|
||||||
"@reduxjs/toolkit": "1.5.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",
|
||||||
|
"@reduxjs/toolkit": "1.9.7",
|
||||||
|
"@tanstack/react-query": "4.36.1",
|
||||||
|
"broadcast-channel": "^7.0.0",
|
||||||
"classnames": "2.2.6",
|
"classnames": "2.2.6",
|
||||||
"core-js": "3.8.1",
|
"core-js": "3.8.1",
|
||||||
"email-validator": "2.0.4",
|
"email-validator": "2.0.4",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"formik": "2.2.6",
|
"formik": "2.2.6",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"moment": "2.29.2",
|
"moment": "2.29.4",
|
||||||
"prop-types": "15.7.2",
|
"prop-types": "15.7.2",
|
||||||
"react": "16.14.0",
|
"react": "17.0.2",
|
||||||
"react-dom": "16.14.0",
|
"react-datepicker": "^4.13.0",
|
||||||
|
"react-dom": "17.0.2",
|
||||||
"react-helmet": "^6.1.0",
|
"react-helmet": "^6.1.0",
|
||||||
"react-redux": "7.1.3",
|
"react-redux": "7.2.9",
|
||||||
"react-responsive": "8.1.0",
|
"react-responsive": "9.0.2",
|
||||||
"react-router": "5.1.2",
|
"react-router": "6.16.0",
|
||||||
"react-router-dom": "5.1.2",
|
"react-router-dom": "6.16.0",
|
||||||
"react-transition-group": "4.4.1",
|
"react-textarea-autosize": "^8.4.1",
|
||||||
|
"react-transition-group": "4.4.5",
|
||||||
"redux": "4.0.5",
|
"redux": "4.0.5",
|
||||||
"regenerator-runtime": "0.13.7",
|
"regenerator-runtime": "0.13.7",
|
||||||
|
"universal-cookie": "^4.0.4",
|
||||||
"uuid": "^3.4.0",
|
"uuid": "^3.4.0",
|
||||||
"yup": "0.31.1"
|
"yup": "0.31.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edx/browserslist-config": "1.0.0",
|
"@edx/browserslist-config": "1.2.0",
|
||||||
"@edx/frontend-build": "12.8.38",
|
"@edx/frontend-build": "13.0.5",
|
||||||
|
"@edx/react-unit-test-utils": "^1.7.0",
|
||||||
"@edx/reactifex": "^1.0.3",
|
"@edx/reactifex": "^1.0.3",
|
||||||
"@testing-library/jest-dom": "5.16.4",
|
"@edx/stylelint-config-edx": "^2.3.0",
|
||||||
"@testing-library/react": "12.1.1",
|
"@edx/typescript-config": "^1.0.1",
|
||||||
|
"@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",
|
"@testing-library/user-event": "^13.2.1",
|
||||||
"axios-mock-adapter": "1.20.0",
|
"axios-mock-adapter": "1.22.0",
|
||||||
"enzyme": "3.11.0",
|
"glob": "7.2.3",
|
||||||
"enzyme-adapter-react-16": "1.15.6",
|
"husky": "^7.0.4",
|
||||||
"enzyme-to-json": "^3.6.2",
|
"jest-canvas-mock": "^2.5.2",
|
||||||
"glob": "7.1.6",
|
"jest-expect-message": "^1.1.3",
|
||||||
"husky": "3.1.0",
|
"react-test-renderer": "17.0.2",
|
||||||
"react-test-renderer": "16.9.0",
|
"reactifex": "1.1.1",
|
||||||
"reactifex": "1.1.1"
|
"ts-loader": "^9.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"decode-uri-component": ">=0.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
{
|
{
|
||||||
"extends": [
|
"extends": [
|
||||||
"config:base",
|
"config:base",
|
||||||
"schedule:daily",
|
"schedule:weekly",
|
||||||
":rebaseStalePrs",
|
":rebaseStalePrs",
|
||||||
":semanticCommits"
|
":semanticCommits",
|
||||||
|
":dependencyDashboard"
|
||||||
],
|
],
|
||||||
|
"timezone": "America/New_York",
|
||||||
"patch": {
|
"patch": {
|
||||||
"automerge": true
|
"automerge": false
|
||||||
},
|
},
|
||||||
"rebaseStalePrs": true,
|
"rebaseStalePrs": true,
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"matchPackagePatterns": ["@edx"],
|
"extends": [
|
||||||
|
"schedule:daily"
|
||||||
|
],
|
||||||
|
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||||
"matchUpdateTypes": ["minor", "patch"],
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
"automerge": true
|
"automerge": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackagePatterns": ["@edx/frontend-lib-content-components"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"automerge": false,
|
||||||
|
"schedule": [
|
||||||
|
"after 1am",
|
||||||
|
"before 11pm"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import Footer from '@edx/frontend-component-footer';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useLocation,
|
useLocation,
|
||||||
} from 'react-router-dom';
|
} 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 { fetchCourseDetail } from './data/thunks';
|
||||||
import { useModel } from './generic/model-store';
|
import { useModel } from './generic/model-store';
|
||||||
|
import NotFoundAlert from './generic/NotFoundAlert';
|
||||||
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
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 { RequestStatus } from './data/constants';
|
||||||
import Loading from './generic/Loading';
|
import Loading from './generic/Loading';
|
||||||
|
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from './generic/data/thunks';
|
||||||
|
import { getUserPermissions } from './generic/data/selectors';
|
||||||
|
|
||||||
const AppHeader = ({
|
const AppHeader = ({
|
||||||
courseNumber, courseOrg, courseTitle, courseId,
|
courseNumber, courseOrg, courseTitle, courseId,
|
||||||
@@ -37,17 +40,16 @@ AppHeader.defaultProps = {
|
|||||||
courseOrg: null,
|
courseOrg: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppFooter = () => (
|
|
||||||
<div className="mt-6">
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const CourseAuthoringPage = ({ courseId, children }) => {
|
const CourseAuthoringPage = ({ courseId, children }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const userPermissions = useSelector(getUserPermissions);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchCourseDetail(courseId));
|
dispatch(fetchCourseDetail(courseId));
|
||||||
|
dispatch(fetchUserPermissionsEnabledFlag());
|
||||||
|
if (!userPermissions) {
|
||||||
|
dispatch(fetchUserPermissionsQuery(courseId));
|
||||||
|
}
|
||||||
}, [courseId]);
|
}, [courseId]);
|
||||||
|
|
||||||
const courseDetail = useModel('courseDetails', courseId);
|
const courseDetail = useModel('courseDetails', courseId);
|
||||||
@@ -56,31 +58,39 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
|||||||
const courseOrg = courseDetail ? courseDetail.org : null;
|
const courseOrg = courseDetail ? courseDetail.org : null;
|
||||||
const courseTitle = courseDetail ? courseDetail.name : courseId;
|
const courseTitle = courseDetail ? courseDetail.name : courseId;
|
||||||
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
|
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 { pathname } = useLocation();
|
||||||
|
const isEditor = pathname.includes('/editor');
|
||||||
|
|
||||||
|
if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) {
|
||||||
|
return (
|
||||||
|
<NotFoundAlert />
|
||||||
|
);
|
||||||
|
}
|
||||||
if (courseAppsApiStatus === RequestStatus.DENIED) {
|
if (courseAppsApiStatus === RequestStatus.DENIED) {
|
||||||
return (
|
return (
|
||||||
<PermissionDeniedAlert />
|
<PermissionDeniedAlert />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
|
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
|
||||||
{/* While V2 Editors are tempoarily served from thier own pages
|
{/* While V2 Editors are temporarily served from their own pages
|
||||||
using url pattern containing /editor/,
|
using url pattern containing /editor/,
|
||||||
we shouldn't have the header and footer on these pages.
|
we shouldn't have the header and footer on these pages.
|
||||||
This functionality will be removed in TNL-9591 */}
|
This functionality will be removed in TNL-9591 */}
|
||||||
{inProgress ? !pathname.includes('/editor/') && <Loading />
|
{inProgress ? !isEditor && <Loading />
|
||||||
: (
|
: (!isEditor && (
|
||||||
<AppHeader
|
<AppHeader
|
||||||
courseNumber={courseNumber}
|
courseNumber={courseNumber}
|
||||||
courseOrg={courseOrg}
|
courseOrg={courseOrg}
|
||||||
courseTitle={courseTitle}
|
courseTitle={courseTitle}
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
/>
|
/>
|
||||||
)}
|
)
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
{!inProgress && <AppFooter />}
|
{!inProgress && !isEditor && <StudioFooter />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
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 { getConfig, initializeMockApp } from '@edx/frontend-platform';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
@@ -12,6 +12,7 @@ import CourseAuthoringPage from './CourseAuthoringPage';
|
|||||||
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
||||||
import { executeThunk } from './utils';
|
import { executeThunk } from './utils';
|
||||||
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
||||||
|
import { fetchCourseDetail } from './data/thunks';
|
||||||
|
|
||||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||||
let mockPathname = '/evilguy/';
|
let mockPathname = '/evilguy/';
|
||||||
@@ -23,50 +24,18 @@ jest.mock('react-router-dom', () => ({
|
|||||||
}));
|
}));
|
||||||
let axiosMock;
|
let axiosMock;
|
||||||
let store;
|
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 () => {
|
beforeEach(() => {
|
||||||
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
|
initializeMockApp({
|
||||||
const courseAppsApiUrl = `${apiBaseUrl}/api/course_apps/v1/apps`;
|
authenticatedUser: {
|
||||||
axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(403, {
|
userId: 3,
|
||||||
response: { status: 403 },
|
username: 'abc123',
|
||||||
});
|
administrator: true,
|
||||||
|
roles: [],
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
store = initializeStore();
|
||||||
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Editor Pages Load no header', () => {
|
describe('Editor Pages Load no header', () => {
|
||||||
@@ -78,18 +47,6 @@ describe('Editor Pages Load no header', () => {
|
|||||||
});
|
});
|
||||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
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 () => {
|
test('renders no loading wheel on editor pages', async () => {
|
||||||
mockPathname = '/editor/';
|
mockPathname = '/editor/';
|
||||||
await mockStoreSuccess();
|
await mockStoreSuccess();
|
||||||
@@ -121,3 +78,56 @@ describe('Editor Pages Load no header', () => {
|
|||||||
expect(wrapper.queryByRole('status')).toBeInTheDocument();
|
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,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import {
|
||||||
import { Switch, useRouteMatch } from 'react-router';
|
Navigate, Routes, Route, useParams,
|
||||||
import { PageRoute } from '@edx/frontend-platform/react';
|
} from 'react-router-dom';
|
||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import { PageWrap } from '@edx/frontend-platform/react';
|
||||||
import CourseAuthoringPage from './CourseAuthoringPage';
|
import CourseAuthoringPage from './CourseAuthoringPage';
|
||||||
import { PagesAndResources } from './pages-and-resources';
|
import { PagesAndResources } from './pages-and-resources';
|
||||||
import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings';
|
|
||||||
import EditorContainer from './editors/EditorContainer';
|
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* As of this writing, these routes are mounted at a path prefixed with the following:
|
* As of this writing, these routes are mounted at a path prefixed with the following:
|
||||||
@@ -23,32 +37,81 @@ import EditorContainer from './editors/EditorContainer';
|
|||||||
* can move the Header/Footer rendering to this component and likely pull the course detail loading
|
* 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.
|
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
|
||||||
*/
|
*/
|
||||||
const CourseAuthoringRoutes = ({ courseId }) => {
|
const CourseAuthoringRoutes = () => {
|
||||||
const { path } = useRouteMatch();
|
const { courseId } = useParams();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CourseAuthoringPage courseId={courseId}>
|
<CourseAuthoringPage courseId={courseId}>
|
||||||
<Switch>
|
<Routes>
|
||||||
<PageRoute path={`${path}/pages-and-resources`}>
|
<Route
|
||||||
<PagesAndResources courseId={courseId} />
|
path="/"
|
||||||
</PageRoute>
|
element={<PageWrap><CourseOutline courseId={courseId} /></PageWrap>}
|
||||||
<PageRoute path={`${path}/proctored-exam-settings`}>
|
/>
|
||||||
<ProctoredExamSettings courseId={courseId} />
|
<Route
|
||||||
</PageRoute>
|
path="course_info"
|
||||||
<PageRoute path={`${path}/editor/:blockType/:blockId`}>
|
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
|
||||||
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
|
/>
|
||||||
&& (
|
<Route
|
||||||
<EditorContainer
|
path="assets"
|
||||||
courseId={courseId}
|
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
|
||||||
/>
|
/>
|
||||||
)}
|
<Route
|
||||||
</PageRoute>
|
path="videos"
|
||||||
</Switch>
|
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
|
||||||
|
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>}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
</CourseAuthoringPage>
|
</CourseAuthoringPage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
CourseAuthoringRoutes.propTypes = {
|
|
||||||
courseId: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CourseAuthoringRoutes;
|
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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
301
src/advanced-settings/AdvancedSettings.jsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import {
|
||||||
|
Container, Button, Layout, StatefulButton, TransitionReplace,
|
||||||
|
} from '@edx/paragon';
|
||||||
|
import { CheckCircle, Info, Warning } from '@edx/paragon/icons';
|
||||||
|
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
import Placeholder from '@edx/frontend-lib-content-components';
|
||||||
|
|
||||||
|
import AlertProctoringError from '../generic/AlertProctoringError';
|
||||||
|
import { useModel } from '../generic/model-store';
|
||||||
|
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||||
|
import { parseArrayOrObjectValues } from '../utils';
|
||||||
|
import { RequestStatus } from '../data/constants';
|
||||||
|
import SubHeader from '../generic/sub-header/SubHeader';
|
||||||
|
import AlertMessage from '../generic/alert-message';
|
||||||
|
import { fetchCourseAppSettings, updateCourseAppSetting, fetchProctoringExamErrors } from './data/thunks';
|
||||||
|
import {
|
||||||
|
getCourseAppSettings, getSavingStatus, getProctoringExamErrors, getSendRequestErrors, getLoadingStatus,
|
||||||
|
} from './data/selectors';
|
||||||
|
import SettingCard from './setting-card/SettingCard';
|
||||||
|
import SettingsSidebar from './settings-sidebar/SettingsSidebar';
|
||||||
|
import validateAdvancedSettingsData from './utils';
|
||||||
|
import messages from './messages';
|
||||||
|
import ModalError from './modal-error/ModalError';
|
||||||
|
import getPageHeadTitle from '../generic/utils';
|
||||||
|
import { useUserPermissions } from '../generic/hooks';
|
||||||
|
import { getUserPermissionsEnabled } from '../generic/data/selectors';
|
||||||
|
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
|
||||||
|
|
||||||
|
const AdvancedSettings = ({ intl, courseId }) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
|
||||||
|
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||||
|
const [errorModal, showErrorModal] = useState(false);
|
||||||
|
const [editedSettings, setEditedSettings] = useState({});
|
||||||
|
const [errorFields, setErrorFields] = useState([]);
|
||||||
|
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
|
||||||
|
const [isQueryPending, setIsQueryPending] = useState(false);
|
||||||
|
const [isEditableState, setIsEditableState] = useState(false);
|
||||||
|
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
|
||||||
|
|
||||||
|
const courseDetails = useModel('courseDetails', courseId);
|
||||||
|
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
|
||||||
|
|
||||||
|
const { checkPermission } = useUserPermissions();
|
||||||
|
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||||
|
const viewOnly = checkPermission('view_course_settings');
|
||||||
|
const showPermissionDeniedAlert = userPermissionsEnabled && (
|
||||||
|
!checkPermission('manage_advanced_settings') && !checkPermission('view_course_settings')
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchCourseAppSettings(courseId));
|
||||||
|
dispatch(fetchProctoringExamErrors(courseId));
|
||||||
|
}, [courseId]);
|
||||||
|
|
||||||
|
const advancedSettingsData = useSelector(getCourseAppSettings);
|
||||||
|
const savingStatus = useSelector(getSavingStatus);
|
||||||
|
const proctoringExamErrors = useSelector(getProctoringExamErrors);
|
||||||
|
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
|
||||||
|
const loadingSettingsStatus = useSelector(getLoadingStatus);
|
||||||
|
|
||||||
|
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
|
||||||
|
const updateSettingsButtonState = {
|
||||||
|
labels: {
|
||||||
|
default: intl.formatMessage(messages.buttonSaveText),
|
||||||
|
pending: intl.formatMessage(messages.buttonSavingText),
|
||||||
|
},
|
||||||
|
disabledStates: ['pending'],
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
proctoringErrors,
|
||||||
|
mfeProctoredExamSettingsUrl,
|
||||||
|
} = proctoringExamErrors;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||||
|
setIsQueryPending(false);
|
||||||
|
setShowSuccessAlert(true);
|
||||||
|
setIsEditableState(false);
|
||||||
|
setTimeout(() => setShowSuccessAlert(false), 15000);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
showSaveSettingsPrompt(false);
|
||||||
|
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
|
||||||
|
setErrorFields(settingsWithSendErrors);
|
||||||
|
showErrorModal(true);
|
||||||
|
}
|
||||||
|
}, [savingStatus]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
if (showPermissionDeniedAlert) {
|
||||||
|
return (
|
||||||
|
<PermissionDeniedAlert />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (loadingSettingsStatus === RequestStatus.DENIED) {
|
||||||
|
return (
|
||||||
|
<div className="row justify-content-center m-6">
|
||||||
|
<Placeholder />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetSettingsValues = () => {
|
||||||
|
setIsEditableState(false);
|
||||||
|
showErrorModal(false);
|
||||||
|
setEditedSettings({});
|
||||||
|
showSaveSettingsPrompt(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSettingBlur = () => {
|
||||||
|
validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateAdvancedSettingsData = () => {
|
||||||
|
const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
|
||||||
|
if (isValid) {
|
||||||
|
setIsQueryPending(true);
|
||||||
|
} else {
|
||||||
|
showSaveSettingsPrompt(false);
|
||||||
|
showErrorModal(!errorModal);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInternetConnectionFailed = () => {
|
||||||
|
setInternetConnectionError(true);
|
||||||
|
showSaveSettingsPrompt(false);
|
||||||
|
setShowSuccessAlert(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQueryProcessing = () => {
|
||||||
|
setShowSuccessAlert(false);
|
||||||
|
dispatch(updateCourseAppSetting(courseId, parseArrayOrObjectValues(editedSettings)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManuallyChangeClick = (setToState) => {
|
||||||
|
showErrorModal(setToState);
|
||||||
|
showSaveSettingsPrompt(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Container size="xl" className="advanced-settings px-4">
|
||||||
|
<div className="setting-header mt-5">
|
||||||
|
{(proctoringErrors?.length > 0) && (
|
||||||
|
<AlertProctoringError
|
||||||
|
icon={Info}
|
||||||
|
proctoringErrorsData={proctoringErrors}
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-labelledby={intl.formatMessage(messages.alertProctoringAriaLabelledby)}
|
||||||
|
aria-describedby={intl.formatMessage(messages.alertProctoringDescribedby)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TransitionReplace>
|
||||||
|
{showSuccessAlert ? (
|
||||||
|
<AlertMessage
|
||||||
|
key={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
|
||||||
|
show={showSuccessAlert}
|
||||||
|
variant="success"
|
||||||
|
icon={CheckCircle}
|
||||||
|
title={intl.formatMessage(messages.alertSuccess)}
|
||||||
|
description={intl.formatMessage(messages.alertSuccessDescriptions)}
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-labelledby={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
|
||||||
|
aria-describedby={intl.formatMessage(messages.alertSuccessAriaDescribedby)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</TransitionReplace>
|
||||||
|
</div>
|
||||||
|
<section className="setting-items mb-4">
|
||||||
|
<Layout
|
||||||
|
lg={[{ span: 9 }, { span: 3 }]}
|
||||||
|
md={[{ span: 9 }, { span: 3 }]}
|
||||||
|
sm={[{ span: 9 }, { span: 3 }]}
|
||||||
|
xs={[{ span: 9 }, { span: 3 }]}
|
||||||
|
xl={[{ span: 9 }, { span: 3 }]}
|
||||||
|
>
|
||||||
|
<Layout.Element>
|
||||||
|
<SubHeader
|
||||||
|
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||||
|
title={intl.formatMessage(messages.headingTitle)}
|
||||||
|
contentTitle={intl.formatMessage(messages.policy)}
|
||||||
|
/>
|
||||||
|
<article>
|
||||||
|
<div>
|
||||||
|
<section className="setting-items-policies">
|
||||||
|
<div className="small">
|
||||||
|
<FormattedMessage
|
||||||
|
id="course-authoring.advanced-settings.policies.description"
|
||||||
|
defaultMessage="{notice} Do not modify these policies unless you are familiar with their purpose."
|
||||||
|
values={{ notice: <strong>Warning: </strong> }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="setting-items-deprecated-setting">
|
||||||
|
<Button
|
||||||
|
variant={showDeprecated ? 'outline-brand' : 'tertiary'}
|
||||||
|
onClick={() => setShowDeprecated(!showDeprecated)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="course-authoring.advanced-settings.deprecated.button.text"
|
||||||
|
defaultMessage="{visibility} deprecated settings"
|
||||||
|
values={{
|
||||||
|
visibility:
|
||||||
|
showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText)
|
||||||
|
: intl.formatMessage(messages.deprecatedButtonShowText),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ul className="setting-items-list p-0">
|
||||||
|
{Object.keys(advancedSettingsData).map((settingName) => {
|
||||||
|
const settingData = advancedSettingsData[settingName];
|
||||||
|
if (settingData.deprecated && !showDeprecated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SettingCard
|
||||||
|
key={settingName}
|
||||||
|
settingData={settingData}
|
||||||
|
name={settingName}
|
||||||
|
showSaveSettingsPrompt={showSaveSettingsPrompt}
|
||||||
|
saveSettingsPrompt={saveSettingsPrompt}
|
||||||
|
setEdited={setEditedSettings}
|
||||||
|
handleBlur={handleSettingBlur}
|
||||||
|
isEditableState={isEditableState}
|
||||||
|
setIsEditableState={setIsEditableState}
|
||||||
|
disableForm={viewOnly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Layout.Element>
|
||||||
|
<Layout.Element>
|
||||||
|
<SettingsSidebar
|
||||||
|
courseId={courseId}
|
||||||
|
proctoredExamSettingsUrl={mfeProctoredExamSettingsUrl}
|
||||||
|
/>
|
||||||
|
</Layout.Element>
|
||||||
|
</Layout>
|
||||||
|
</section>
|
||||||
|
</Container>
|
||||||
|
<div className="alert-toast">
|
||||||
|
{isQueryPending && (
|
||||||
|
<InternetConnectionAlert
|
||||||
|
isFailed={savingStatus === RequestStatus.FAILED}
|
||||||
|
isQueryPending={isQueryPending}
|
||||||
|
onQueryProcessing={handleQueryProcessing}
|
||||||
|
onInternetConnectionFailed={handleInternetConnectionFailed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<AlertMessage
|
||||||
|
show={saveSettingsPrompt}
|
||||||
|
aria-hidden={saveSettingsPrompt}
|
||||||
|
aria-labelledby={intl.formatMessage(messages.alertWarningAriaLabelledby)}
|
||||||
|
aria-describedby={intl.formatMessage(messages.alertWarningAriaDescribedby)}
|
||||||
|
role="dialog"
|
||||||
|
actions={[
|
||||||
|
!isQueryPending && (
|
||||||
|
<Button variant="tertiary" onClick={handleResetSettingsValues}>
|
||||||
|
{intl.formatMessage(messages.buttonCancelText)}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
<StatefulButton
|
||||||
|
key="statefulBtn"
|
||||||
|
onClick={handleUpdateAdvancedSettingsData}
|
||||||
|
state={isQueryPending ? RequestStatus.PENDING : 'default'}
|
||||||
|
{...updateSettingsButtonState}
|
||||||
|
/>,
|
||||||
|
].filter(Boolean)}
|
||||||
|
variant="warning"
|
||||||
|
icon={Warning}
|
||||||
|
title={intl.formatMessage(messages.alertWarning)}
|
||||||
|
description={intl.formatMessage(messages.alertWarningDescriptions)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ModalError
|
||||||
|
isError={errorModal}
|
||||||
|
showErrorModal={(setToState) => handleManuallyChangeClick(setToState)}
|
||||||
|
handleUndoChanges={handleResetSettingsValues}
|
||||||
|
settingsData={advancedSettingsData}
|
||||||
|
errorList={errorFields.length > 0 ? errorFields : []}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AdvancedSettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
courseId: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(AdvancedSettings);
|
||||||
211
src/advanced-settings/AdvancedSettings.test.jsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { initializeMockApp } 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 {
|
||||||
|
render,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
|
||||||
|
import initializeStore from '../store';
|
||||||
|
import { executeThunk } from '../utils';
|
||||||
|
import { advancedSettingsMock } from './__mocks__';
|
||||||
|
import { getCourseAdvancedSettingsApiUrl } from './data/api';
|
||||||
|
import { updateCourseAppSetting } from './data/thunks';
|
||||||
|
import AdvancedSettings from './AdvancedSettings';
|
||||||
|
import messages from './messages';
|
||||||
|
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
|
||||||
|
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
|
||||||
|
|
||||||
|
let axiosMock;
|
||||||
|
let store;
|
||||||
|
const mockPathname = '/foo-bar';
|
||||||
|
const courseId = '123';
|
||||||
|
const userId = 3;
|
||||||
|
const userPermissionsData = { permissions: ['view_course_settings', 'manage_advanced_settings'] };
|
||||||
|
|
||||||
|
// Mock the TextareaAutosize component
|
||||||
|
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||||
|
<textarea
|
||||||
|
{...props}
|
||||||
|
onFocus={() => {}}
|
||||||
|
onBlur={() => {}}
|
||||||
|
/>
|
||||||
|
)));
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useLocation: () => ({
|
||||||
|
pathname: mockPathname,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const RootWrapper = () => (
|
||||||
|
<AppProvider store={store}>
|
||||||
|
<IntlProvider locale="en" messages={{}}>
|
||||||
|
<AdvancedSettings intl={injectIntl} courseId={courseId} />
|
||||||
|
</IntlProvider>
|
||||||
|
</AppProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissionsMockStore = async (permissions) => {
|
||||||
|
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, permissions);
|
||||||
|
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||||
|
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||||
|
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||||
|
};
|
||||||
|
|
||||||
|
const permissionDisabledMockStore = async () => {
|
||||||
|
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
|
||||||
|
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('<AdvancedSettings />', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
initializeMockApp({
|
||||||
|
authenticatedUser: {
|
||||||
|
userId,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: true,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store = initializeStore();
|
||||||
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
|
axiosMock
|
||||||
|
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
|
||||||
|
.reply(200, advancedSettingsMock);
|
||||||
|
permissionsMockStore(userPermissionsData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render without errors', async () => {
|
||||||
|
const { getByText } = render(<RootWrapper />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||||
|
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
|
||||||
|
selector: 'h2.sub-header-title',
|
||||||
|
});
|
||||||
|
expect(advancedSettingsElement).toBeInTheDocument();
|
||||||
|
expect(getByText(messages.policy.defaultMessage)).toBeInTheDocument();
|
||||||
|
expect(getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should render setting element', async () => {
|
||||||
|
const { getByText, queryByText } = render(<RootWrapper />);
|
||||||
|
await waitFor(() => {
|
||||||
|
const advancedModuleListTitle = getByText(/Advanced Module List/i);
|
||||||
|
expect(advancedModuleListTitle).toBeInTheDocument();
|
||||||
|
expect(queryByText('Certificate web/html view enabled')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should change to onСhange', async () => {
|
||||||
|
const { getByLabelText } = render(<RootWrapper />);
|
||||||
|
await waitFor(() => {
|
||||||
|
const textarea = getByLabelText(/Advanced Module List/i);
|
||||||
|
expect(textarea).toBeInTheDocument();
|
||||||
|
fireEvent.change(textarea, { target: { value: '[1, 2, 3]' } });
|
||||||
|
expect(textarea.value).toBe('[1, 2, 3]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should display a warning alert', async () => {
|
||||||
|
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||||
|
await waitFor(() => {
|
||||||
|
const textarea = getByLabelText(/Advanced Module List/i);
|
||||||
|
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||||
|
expect(getByText(messages.buttonCancelText.defaultMessage)).toBeInTheDocument();
|
||||||
|
expect(getByText(messages.buttonSaveText.defaultMessage)).toBeInTheDocument();
|
||||||
|
expect(getByText(messages.alertWarning.defaultMessage)).toBeInTheDocument();
|
||||||
|
expect(getByText(messages.alertWarningDescriptions.defaultMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should display a tooltip on clicking on the icon', async () => {
|
||||||
|
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||||
|
await waitFor(() => {
|
||||||
|
const button = getByLabelText(/Show help text/i);
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(getByText(/Enter the names of the advanced modules to use in your course./i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should change deprecated button text ', async () => {
|
||||||
|
const { getByText } = render(<RootWrapper />);
|
||||||
|
await waitFor(() => {
|
||||||
|
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
|
||||||
|
expect(showDeprecatedItemsBtn).toBeInTheDocument();
|
||||||
|
fireEvent.click(showDeprecatedItemsBtn);
|
||||||
|
expect(getByText(/Hide Deprecated Settings/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('should reset to default value on click on Cancel button', async () => {
|
||||||
|
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||||
|
let textarea;
|
||||||
|
await waitFor(() => {
|
||||||
|
textarea = getByLabelText(/Advanced Module List/i);
|
||||||
|
});
|
||||||
|
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||||
|
expect(textarea.value).toBe('[3, 2, 1]');
|
||||||
|
fireEvent.click(getByText(messages.buttonCancelText.defaultMessage));
|
||||||
|
expect(textarea.value).toBe('[]');
|
||||||
|
});
|
||||||
|
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
|
||||||
|
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||||
|
let textarea;
|
||||||
|
await waitFor(() => {
|
||||||
|
textarea = getByLabelText(/Advanced Module List/i);
|
||||||
|
});
|
||||||
|
fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } });
|
||||||
|
expect(textarea.value).toBe('[3, 2, 1,');
|
||||||
|
fireEvent.click(getByText('Save changes'));
|
||||||
|
fireEvent.click(getByText('Change manually'));
|
||||||
|
expect(textarea.value).toBe('[3, 2, 1,');
|
||||||
|
});
|
||||||
|
it('should show success alert after save', async () => {
|
||||||
|
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||||
|
let textarea;
|
||||||
|
await waitFor(() => {
|
||||||
|
textarea = getByLabelText(/Advanced Module List/i);
|
||||||
|
});
|
||||||
|
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||||
|
expect(textarea.value).toBe('[3, 2, 1]');
|
||||||
|
axiosMock
|
||||||
|
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
|
||||||
|
.reply(200, {
|
||||||
|
...advancedSettingsMock,
|
||||||
|
advancedModules: {
|
||||||
|
...advancedSettingsMock.advancedModules,
|
||||||
|
value: [3, 2, 1],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fireEvent.click(getByText('Save changes'));
|
||||||
|
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
|
||||||
|
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('should shows the PermissionDeniedAlert when there are not the right user permissions', async () => {
|
||||||
|
const permissionsData = { permissions: ['view'] };
|
||||||
|
await permissionsMockStore(permissionsData);
|
||||||
|
|
||||||
|
const { queryByText } = render(<RootWrapper />);
|
||||||
|
await waitFor(() => {
|
||||||
|
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
|
||||||
|
expect(permissionDeniedAlert).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should not show the PermissionDeniedAlert when the User Permissions Flag is not enabled', async () => {
|
||||||
|
await permissionDisabledMockStore();
|
||||||
|
|
||||||
|
const { queryByText } = render(<RootWrapper />);
|
||||||
|
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
|
||||||
|
expect(permissionDeniedAlert).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('should be view only if the permission is set for viewOnly', async () => {
|
||||||
|
const permissions = { permissions: ['view_course_settings'] };
|
||||||
|
await permissionsMockStore(permissions);
|
||||||
|
const { getByLabelText } = render(<RootWrapper />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByLabelText('Advanced Module List')).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
16
src/advanced-settings/__mocks__/advancedSettings.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module.exports = {
|
||||||
|
advancedModules: {
|
||||||
|
deprecated: false,
|
||||||
|
displayName: 'Advanced Module List',
|
||||||
|
help: 'Enter the names of the advanced modules to use in your course.',
|
||||||
|
hideOnEnabledPublisher: false,
|
||||||
|
value: [],
|
||||||
|
},
|
||||||
|
certHtmlViewEnabled: {
|
||||||
|
deprecated: true,
|
||||||
|
display_name: 'Certificate web/html view enabled',
|
||||||
|
help: 'If true, certificate Web/HTML views are enabled for the course.',
|
||||||
|
hide_on_enabled_publisher: false,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
2
src/advanced-settings/__mocks__/index.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
|
export { default as advancedSettingsMock } from './advancedSettings';
|
||||||
41
src/advanced-settings/data/api.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { convertObjectToSnakeCase } from '../../utils';
|
||||||
|
|
||||||
|
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||||
|
export const getCourseAdvancedSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings/${courseId}`;
|
||||||
|
const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/proctoring_errors/`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get's advanced setting for a course.
|
||||||
|
* @param {string} courseId
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
export async function getCourseAdvancedSettings(courseId) {
|
||||||
|
const { data } = await getAuthenticatedHttpClient()
|
||||||
|
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
|
||||||
|
return camelCaseObject(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates advanced setting for a course.
|
||||||
|
* @param {string} courseId
|
||||||
|
* @param {object} settings
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
export async function updateCourseAdvancedSettings(courseId, settings) {
|
||||||
|
const { data } = await getAuthenticatedHttpClient()
|
||||||
|
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
|
||||||
|
return camelCaseObject(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets proctoring exam errors.
|
||||||
|
* @param {string} courseId
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
export async function getProctoringExamErrors(courseId) {
|
||||||
|
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
|
||||||
|
return camelCaseObject(data);
|
||||||
|
}
|
||||||
5
src/advanced-settings/data/selectors.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const getLoadingStatus = (state) => state.advancedSettings.loadingStatus;
|
||||||
|
export const getCourseAppSettings = state => state.advancedSettings.courseAppSettings;
|
||||||
|
export const getSavingStatus = (state) => state.advancedSettings.savingStatus;
|
||||||
|
export const getProctoringExamErrors = (state) => state.advancedSettings.proctoringErrors;
|
||||||
|
export const getSendRequestErrors = (state) => state.advancedSettings.sendRequestErrors.developer_message;
|
||||||
48
src/advanced-settings/data/slice.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import { RequestStatus } from '../../data/constants';
|
||||||
|
|
||||||
|
const slice = createSlice({
|
||||||
|
name: 'advancedSettings',
|
||||||
|
initialState: {
|
||||||
|
loadingStatus: RequestStatus.IN_PROGRESS,
|
||||||
|
savingStatus: '',
|
||||||
|
courseAppSettings: {},
|
||||||
|
proctoringErrors: {},
|
||||||
|
sendRequestErrors: {},
|
||||||
|
},
|
||||||
|
reducers: {
|
||||||
|
updateLoadingStatus: (state, { payload }) => {
|
||||||
|
state.loadingStatus = payload.status;
|
||||||
|
},
|
||||||
|
updateSavingStatus: (state, { payload }) => {
|
||||||
|
state.savingStatus = payload.status;
|
||||||
|
},
|
||||||
|
fetchCourseAppsSettingsSuccess: (state, { payload }) => {
|
||||||
|
Object.assign(state.courseAppSettings, payload);
|
||||||
|
},
|
||||||
|
updateCourseAppsSettingsSuccess: (state, { payload }) => {
|
||||||
|
Object.assign(state.courseAppSettings, payload);
|
||||||
|
},
|
||||||
|
getDataSendErrors: (state, { payload }) => {
|
||||||
|
Object.assign(state.sendRequestErrors, payload);
|
||||||
|
},
|
||||||
|
fetchProctoringExamErrorsSuccess: (state, { payload }) => {
|
||||||
|
Object.assign(state.proctoringErrors, payload);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
updateLoadingStatus,
|
||||||
|
updateSavingStatus,
|
||||||
|
getDataSendErrors,
|
||||||
|
fetchCourseAppsSettingsSuccess,
|
||||||
|
updateCourseAppsSettingsSuccess,
|
||||||
|
fetchProctoringExamErrorsSuccess,
|
||||||
|
} = slice.actions;
|
||||||
|
|
||||||
|
export const {
|
||||||
|
reducer,
|
||||||
|
} = slice;
|
||||||
85
src/advanced-settings/data/thunks.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { RequestStatus } from '../../data/constants';
|
||||||
|
import {
|
||||||
|
getCourseAdvancedSettings,
|
||||||
|
updateCourseAdvancedSettings,
|
||||||
|
getProctoringExamErrors,
|
||||||
|
} from './api';
|
||||||
|
import {
|
||||||
|
fetchCourseAppsSettingsSuccess,
|
||||||
|
updateCourseAppsSettingsSuccess,
|
||||||
|
updateLoadingStatus,
|
||||||
|
updateSavingStatus,
|
||||||
|
fetchProctoringExamErrorsSuccess,
|
||||||
|
getDataSendErrors,
|
||||||
|
} from './slice';
|
||||||
|
|
||||||
|
export function fetchCourseAppSettings(courseId) {
|
||||||
|
return async (dispatch) => {
|
||||||
|
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settingValues = await getCourseAdvancedSettings(courseId);
|
||||||
|
const sortedDisplayName = [];
|
||||||
|
Object.values(settingValues).forEach(value => {
|
||||||
|
const { displayName } = value;
|
||||||
|
sortedDisplayName.push(displayName);
|
||||||
|
});
|
||||||
|
const sortedSettingValues = {};
|
||||||
|
sortedDisplayName.sort().forEach((displayName => {
|
||||||
|
Object.entries(settingValues).forEach(([key, value]) => {
|
||||||
|
if (value.displayName === displayName) {
|
||||||
|
sortedSettingValues[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
dispatch(fetchCourseAppsSettingsSuccess(sortedSettingValues));
|
||||||
|
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response && error.response.status === 403) {
|
||||||
|
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED }));
|
||||||
|
} else {
|
||||||
|
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCourseAppSetting(courseId, settings) {
|
||||||
|
return async (dispatch) => {
|
||||||
|
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settingValues = await updateCourseAdvancedSettings(courseId, settings);
|
||||||
|
dispatch(updateCourseAppsSettingsSuccess(settingValues));
|
||||||
|
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
const { customAttributes: { httpErrorResponseData } } = error;
|
||||||
|
errorData = JSON.parse(httpErrorResponseData);
|
||||||
|
} catch (err) {
|
||||||
|
errorData = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(getDataSendErrors(errorData));
|
||||||
|
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchProctoringExamErrors(courseId) {
|
||||||
|
return async (dispatch) => {
|
||||||
|
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settingValues = await getProctoringExamErrors(courseId);
|
||||||
|
dispatch(fetchProctoringExamErrorsSuccess(settingValues));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
2
src/advanced-settings/index.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
export { default as AdvancedSettings } from './AdvancedSettings';
|
||||||
86
src/advanced-settings/messages.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
headingTitle: {
|
||||||
|
id: 'course-authoring.advanced-settings.heading.title',
|
||||||
|
defaultMessage: 'Advanced settings',
|
||||||
|
},
|
||||||
|
headingSubtitle: {
|
||||||
|
id: 'course-authoring.advanced-settings.heading.subtitle',
|
||||||
|
defaultMessage: 'Settings',
|
||||||
|
},
|
||||||
|
policy: {
|
||||||
|
id: 'course-authoring.advanced-settings.policies.title',
|
||||||
|
defaultMessage: 'Manual policy definition',
|
||||||
|
},
|
||||||
|
alertWarning: {
|
||||||
|
id: 'course-authoring.advanced-settings.alert.warning',
|
||||||
|
defaultMessage: "You've made some changes",
|
||||||
|
},
|
||||||
|
alertWarningDescriptions: {
|
||||||
|
id: 'course-authoring.advanced-settings.alert.warning.descriptions',
|
||||||
|
defaultMessage: 'Your changes will not take effect until you save your progress. Take care with key and value formatting, as validation is not implemented.',
|
||||||
|
},
|
||||||
|
alertSuccess: {
|
||||||
|
id: 'course-authoring.advanced-settings.alert.success',
|
||||||
|
defaultMessage: 'Your policy changes have been saved.',
|
||||||
|
},
|
||||||
|
alertSuccessDescriptions: {
|
||||||
|
id: 'course-authoring.advanced-settings.alert.success.descriptions',
|
||||||
|
defaultMessage: 'No validation is performed on policy keys or value pairs. If you are having difficulties, check your formatting.',
|
||||||
|
},
|
||||||
|
alertProctoringError: {
|
||||||
|
id: 'course-authoring.advanced-settings.alert.proctoring.error',
|
||||||
|
defaultMessage: 'This course has protected exam setting that are incomplete or invalid.',
|
||||||
|
},
|
||||||
|
alertProctoringErrorDescriptions: {
|
||||||
|
id: 'course-authoring.advanced-settings.alert.proctoring.error.descriptions',
|
||||||
|
defaultMessage: 'You will be unable to make changes until the following setting are updated on the page below.',
|
||||||
|
},
|
||||||
|
buttonSaveText: {
|
||||||
|
id: 'course-authoring.advanced-settings.alert.button.save',
|
||||||
|
defaultMessage: 'Save changes',
|
||||||
|
},
|
||||||
|
buttonSavingText: {
|
||||||
|
id: 'course-authoring.advanced-settings.alert.button.saving',
|
||||||
|
defaultMessage: 'Saving',
|
||||||
|
},
|
||||||
|
buttonCancelText: {
|
||||||
|
id: 'course-authoring.advanced-settings.alert.button.cancel',
|
||||||
|
defaultMessage: 'Cancel',
|
||||||
|
},
|
||||||
|
deprecatedButtonShowText: {
|
||||||
|
id: 'course-authoring.advanced-settings.deprecated.button.show',
|
||||||
|
defaultMessage: 'Show',
|
||||||
|
},
|
||||||
|
deprecatedButtonHideText: {
|
||||||
|
id: 'course-authoring.advanced-settings.deprecated.button.hide',
|
||||||
|
defaultMessage: 'Hide',
|
||||||
|
},
|
||||||
|
alertWarningAriaLabelledby: {
|
||||||
|
id: 'course-authoring.advanced-settings.alert.warning.aria.labelledby',
|
||||||
|
defaultMessage: 'notification-warning-title',
|
||||||
|
},
|
||||||
|
alertWarningAriaDescribedby: {
|
||||||
|
id: 'course-authoring.advanced-settings.alert.warning.aria.describedby',
|
||||||
|
defaultMessage: 'notification-warning-description',
|
||||||
|
},
|
||||||
|
alertSuccessAriaLabelledby: {
|
||||||
|
id: 'course-authoring.advanced-settings.alert.success.aria.labelledby',
|
||||||
|
defaultMessage: 'alert-confirmation-title',
|
||||||
|
},
|
||||||
|
alertSuccessAriaDescribedby: {
|
||||||
|
id: 'course-authoring.advanced-settings.alert.success.aria.describedby',
|
||||||
|
defaultMessage: 'alert-confirmation-description',
|
||||||
|
},
|
||||||
|
alertProctoringAriaLabelledby: {
|
||||||
|
id: 'course-authoring.advanced-settings.alert.proctoring.error.aria.labelledby',
|
||||||
|
defaultMessage: 'alert-danger-title',
|
||||||
|
},
|
||||||
|
alertProctoringDescribedby: {
|
||||||
|
id: 'course-authoring.advanced-settings.alert.proctoring.error.aria.describedby',
|
||||||
|
defaultMessage: 'alert-danger-description',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default messages;
|
||||||
63
src/advanced-settings/modal-error/ModalError.jsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { ActionRow, AlertModal, Button } from '@edx/paragon';
|
||||||
|
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import ModalErrorListItem from './ModalErrorListItem';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
const ModalError = ({
|
||||||
|
intl, isError, handleUndoChanges, showErrorModal, errorList, settingsData,
|
||||||
|
}) => (
|
||||||
|
<AlertModal
|
||||||
|
title={intl.formatMessage(messages.modalErrorTitle)}
|
||||||
|
isOpen={isError}
|
||||||
|
variant="danger"
|
||||||
|
footerNode={(
|
||||||
|
<ActionRow>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
onClick={() => showErrorModal(!isError)}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleUndoChanges}>
|
||||||
|
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
|
||||||
|
</Button>
|
||||||
|
</ActionRow>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
id="course-authoring.advanced-settings.modal.error.description"
|
||||||
|
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
|
||||||
|
Please check the following validation feedbacks and reflect them in your course settings:"
|
||||||
|
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<hr />
|
||||||
|
<ul className="p-0">
|
||||||
|
{errorList.map((settingName) => (
|
||||||
|
<ModalErrorListItem
|
||||||
|
key={settingName.key}
|
||||||
|
settingName={settingName}
|
||||||
|
settingsData={settingsData}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertModal>
|
||||||
|
);
|
||||||
|
|
||||||
|
ModalError.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
isError: PropTypes.bool.isRequired,
|
||||||
|
handleUndoChanges: PropTypes.func.isRequired,
|
||||||
|
showErrorModal: PropTypes.func.isRequired,
|
||||||
|
errorList: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
key: PropTypes.string,
|
||||||
|
message: PropTypes.string,
|
||||||
|
})).isRequired,
|
||||||
|
settingsData: PropTypes.shape({}).isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(ModalError);
|
||||||
58
src/advanced-settings/modal-error/ModalError.test.jsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import ModalError from './ModalError';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
const handleUndoChangesMock = jest.fn();
|
||||||
|
const showErrorModalMock = jest.fn();
|
||||||
|
|
||||||
|
const errorList = [
|
||||||
|
{ key: 'setting1', message: 'Error 1' },
|
||||||
|
{ key: 'setting2', message: 'Error 2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const settingsData = {
|
||||||
|
setting1: 'value1',
|
||||||
|
setting2: 'value2',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RootWrapper = () => (
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<ModalError
|
||||||
|
isError
|
||||||
|
handleUndoChanges={handleUndoChangesMock}
|
||||||
|
showErrorModal={showErrorModalMock}
|
||||||
|
errorList={errorList}
|
||||||
|
settingsData={settingsData}
|
||||||
|
/>
|
||||||
|
</IntlProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('<ModalError />', () => {
|
||||||
|
it('calls handleUndoChanges when "Undo changes" button is clicked', () => {
|
||||||
|
const { getByText } = render(<RootWrapper />);
|
||||||
|
const undoChangesButton = getByText(messages.modalErrorButtonUndoChanges.defaultMessage);
|
||||||
|
fireEvent.click(undoChangesButton);
|
||||||
|
expect(handleUndoChangesMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('calls showErrorModal when "Change manually" button is clicked', () => {
|
||||||
|
const { getByText } = render(<RootWrapper />);
|
||||||
|
const changeManuallyButton = getByText(messages.modalErrorButtonChangeManually.defaultMessage);
|
||||||
|
fireEvent.click(changeManuallyButton);
|
||||||
|
expect(showErrorModalMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('renders error message with correct values', () => {
|
||||||
|
const { getByText } = render(<RootWrapper />);
|
||||||
|
expect(getByText(/There was/i)).toBeInTheDocument();
|
||||||
|
expect(getByText(/2 validation error/i)).toBeInTheDocument();
|
||||||
|
expect(getByText(/while trying to save the course settings in the database. Please check the following validation feedbacks and reflect them in your course settings:/i)).toBeInTheDocument();
|
||||||
|
expect(getByText(messages.modalErrorTitle.defaultMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('renders correct number of errors', () => {
|
||||||
|
const { getByText } = render(<RootWrapper />);
|
||||||
|
expect(getByText('Error 1')).toBeInTheDocument();
|
||||||
|
expect(getByText('Error 2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
31
src/advanced-settings/modal-error/ModalErrorListItem.jsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Alert, Icon } from '@edx/paragon';
|
||||||
|
import { Error } from '@edx/paragon/icons';
|
||||||
|
import { capitalize } from 'lodash';
|
||||||
|
|
||||||
|
import { transformKeysToCamelCase } from '../../utils';
|
||||||
|
|
||||||
|
const ModalErrorListItem = ({ settingName, settingsData }) => {
|
||||||
|
const { displayName } = settingsData[transformKeysToCamelCase(settingName)];
|
||||||
|
return (
|
||||||
|
<li className="modal-error-item">
|
||||||
|
<Alert variant="danger">
|
||||||
|
<h4 className="modal-error-item-title">
|
||||||
|
<Icon src={Error} />{capitalize(displayName)}:
|
||||||
|
</h4>
|
||||||
|
<p className="m-0">{settingName.message}</p>
|
||||||
|
</Alert>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ModalErrorListItem.propTypes = {
|
||||||
|
settingName: PropTypes.shape({
|
||||||
|
key: PropTypes.string,
|
||||||
|
message: PropTypes.string,
|
||||||
|
}).isRequired,
|
||||||
|
settingsData: PropTypes.shape({}).isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalErrorListItem;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import ModalErrorListItem from './ModalErrorListItem';
|
||||||
|
|
||||||
|
const settingName = {
|
||||||
|
key: 'exampleKey',
|
||||||
|
message: 'Error message',
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsData = {
|
||||||
|
exampleKey: {
|
||||||
|
displayName: 'Error field',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const RootWrapper = () => (
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<ModalErrorListItem settingName={settingName} settingsData={settingsData} />
|
||||||
|
</IntlProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('<ModalErrorListItem />', () => {
|
||||||
|
it('renders the display name and error message', () => {
|
||||||
|
const { getByText } = render(<RootWrapper />);
|
||||||
|
expect(getByText('Error field:')).toBeInTheDocument();
|
||||||
|
expect(getByText('Error message')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('renders the alert with variant "danger"', () => {
|
||||||
|
const { getByRole } = render(<RootWrapper />);
|
||||||
|
expect(getByRole('alert')).toHaveClass('alert-danger');
|
||||||
|
});
|
||||||
|
});
|
||||||
18
src/advanced-settings/modal-error/messages.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
modalErrorTitle: {
|
||||||
|
id: 'course-authoring.advanced-settings.modal.error.title',
|
||||||
|
defaultMessage: 'Validation error while saving',
|
||||||
|
},
|
||||||
|
modalErrorButtonChangeManually: {
|
||||||
|
id: 'course-authoring.advanced-settings.modal.error.btn.change-manually',
|
||||||
|
defaultMessage: 'Change manually',
|
||||||
|
},
|
||||||
|
modalErrorButtonUndoChanges: {
|
||||||
|
id: 'course-authoring.advanced-settings.modal.error.btn.undo-changes',
|
||||||
|
defaultMessage: 'Undo changes',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default messages;
|
||||||
124
src/advanced-settings/scss/AdvancedSettings.scss
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
@import "variables";
|
||||||
|
|
||||||
|
.advanced-settings {
|
||||||
|
.help-sidebar {
|
||||||
|
margin-top: 8.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-items-policies {
|
||||||
|
.setting-items-deprecated-setting {
|
||||||
|
float: right;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions,
|
||||||
|
strong {
|
||||||
|
color: $text-color-base;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-card {
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
|
||||||
|
.pgn__card-header .pgn__card-header-title-md {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 .625rem;
|
||||||
|
z-index: $zindex-modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-proctoring-error {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-items-list {
|
||||||
|
li {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pgn__card-header {
|
||||||
|
padding: 0 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pgn__card-status {
|
||||||
|
padding: .625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pgn__card-header-content {
|
||||||
|
margin-top: 1.438rem;
|
||||||
|
margin-bottom: 1.438rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-sidebar-supplementary {
|
||||||
|
.setting-sidebar-supplementary-about {
|
||||||
|
.setting-sidebar-supplementary-about-title {
|
||||||
|
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
|
||||||
|
color: $headings-color;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-sidebar-supplementary-about-descriptions {
|
||||||
|
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
|
||||||
|
color: $text-color-base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-sidebar-supplementary-other-links ul {
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
.setting-sidebar-supplementary-other-link {
|
||||||
|
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
color: $info-500;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-sidebar-supplementary-other-title {
|
||||||
|
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
|
||||||
|
color: $headings-color;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error-item {
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
.pgn__icon {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error-item-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-popup-content {
|
||||||
|
max-width: 200px;
|
||||||
|
color: $white;
|
||||||
|
background-color: $black;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0 0 0 / .15));
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pgn__modal-popup__arrow::after {
|
||||||
|
border-top-color: $black;
|
||||||
|
}
|
||||||
1
src/advanced-settings/scss/_variables.scss
Normal file
@@ -0,0 +1 @@
|
|||||||
|
$text-color-base: $gray-700;
|
||||||
143
src/advanced-settings/setting-card/SettingCard.jsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActionRow,
|
||||||
|
Card,
|
||||||
|
Form,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
ModalPopup,
|
||||||
|
useToggle,
|
||||||
|
} from '@edx/paragon';
|
||||||
|
import { InfoOutline, Warning } from '@edx/paragon/icons';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { capitalize } from 'lodash';
|
||||||
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
import TextareaAutosize from 'react-textarea-autosize';
|
||||||
|
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
const SettingCard = ({
|
||||||
|
name,
|
||||||
|
settingData,
|
||||||
|
handleBlur,
|
||||||
|
setEdited,
|
||||||
|
showSaveSettingsPrompt,
|
||||||
|
saveSettingsPrompt,
|
||||||
|
isEditableState,
|
||||||
|
setIsEditableState,
|
||||||
|
// injected
|
||||||
|
intl,
|
||||||
|
disableForm,
|
||||||
|
}) => {
|
||||||
|
const { deprecated, help, displayName } = settingData;
|
||||||
|
const initialValue = JSON.stringify(settingData.value, null, 4);
|
||||||
|
const [isOpen, open, close] = useToggle(false);
|
||||||
|
const [target, setTarget] = useState(null);
|
||||||
|
const [newValue, setNewValue] = useState(initialValue);
|
||||||
|
|
||||||
|
const handleSettingChange = (e) => {
|
||||||
|
const { value } = e.target;
|
||||||
|
setNewValue(e.target.value);
|
||||||
|
if (value !== initialValue) {
|
||||||
|
if (!saveSettingsPrompt) {
|
||||||
|
showSaveSettingsPrompt(true);
|
||||||
|
}
|
||||||
|
if (!isEditableState) {
|
||||||
|
setIsEditableState(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardBlur = () => {
|
||||||
|
setEdited((prevEditedSettings) => ({
|
||||||
|
...prevEditedSettings,
|
||||||
|
[name]: newValue,
|
||||||
|
}));
|
||||||
|
handleBlur();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="field-group course-advanced-policy-list-item">
|
||||||
|
<Card className="flex-column setting-card">
|
||||||
|
<Card.Body className="d-flex row m-0 align-items-center">
|
||||||
|
<Card.Header
|
||||||
|
className="col-6"
|
||||||
|
title={(
|
||||||
|
<ActionRow>
|
||||||
|
{capitalize(displayName)}
|
||||||
|
<IconButton
|
||||||
|
ref={setTarget}
|
||||||
|
onClick={open}
|
||||||
|
src={InfoOutline}
|
||||||
|
iconAs={Icon}
|
||||||
|
alt={intl.formatMessage(messages.helpButtonText)}
|
||||||
|
variant="primary"
|
||||||
|
className=" ml-1 mr-2"
|
||||||
|
/>
|
||||||
|
<ModalPopup
|
||||||
|
hasArrow
|
||||||
|
placement="right"
|
||||||
|
positionRef={target}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={close}
|
||||||
|
className="pgn__modal-popup__arrow"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="p-2 x-small rounded modal-popup-content"
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
dangerouslySetInnerHTML={{ __html: help }}
|
||||||
|
/>
|
||||||
|
</ModalPopup>
|
||||||
|
<ActionRow.Spacer />
|
||||||
|
</ActionRow>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Card.Section className="col-6 flex-grow-1">
|
||||||
|
<Form.Group className="m-0">
|
||||||
|
<Form.Control
|
||||||
|
as={TextareaAutosize}
|
||||||
|
value={isEditableState ? newValue : initialValue}
|
||||||
|
name={name}
|
||||||
|
onChange={handleSettingChange}
|
||||||
|
aria-label={displayName}
|
||||||
|
onBlur={handleCardBlur}
|
||||||
|
disabled={disableForm}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
</Card.Section>
|
||||||
|
</Card.Body>
|
||||||
|
{deprecated && (
|
||||||
|
<Card.Status icon={Warning} variant="danger">
|
||||||
|
{intl.formatMessage(messages.deprecated)}
|
||||||
|
</Card.Status>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
SettingCard.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
settingData: PropTypes.shape({
|
||||||
|
deprecated: PropTypes.bool,
|
||||||
|
help: PropTypes.string,
|
||||||
|
displayName: PropTypes.string,
|
||||||
|
value: PropTypes.PropTypes.oneOfType([
|
||||||
|
PropTypes.string,
|
||||||
|
PropTypes.bool,
|
||||||
|
PropTypes.number,
|
||||||
|
PropTypes.object,
|
||||||
|
PropTypes.array,
|
||||||
|
]),
|
||||||
|
}).isRequired,
|
||||||
|
setEdited: PropTypes.func.isRequired,
|
||||||
|
showSaveSettingsPrompt: PropTypes.func.isRequired,
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
handleBlur: PropTypes.func.isRequired,
|
||||||
|
saveSettingsPrompt: PropTypes.bool.isRequired,
|
||||||
|
isEditableState: PropTypes.bool.isRequired,
|
||||||
|
setIsEditableState: PropTypes.func.isRequired,
|
||||||
|
disableForm: PropTypes.bool.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(SettingCard);
|
||||||
96
src/advanced-settings/setting-card/SettingCard.test.jsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import SettingCard from './SettingCard';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
const setEdited = jest.fn();
|
||||||
|
const showSaveSettingsPrompt = jest.fn();
|
||||||
|
const setIsEditableState = jest.fn();
|
||||||
|
const handleBlur = jest.fn();
|
||||||
|
|
||||||
|
const settingData = {
|
||||||
|
deprecated: false,
|
||||||
|
help: 'This is a help message',
|
||||||
|
displayName: 'Setting Name',
|
||||||
|
value: 'Setting Value',
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||||
|
<textarea
|
||||||
|
{...props}
|
||||||
|
onFocus={() => {}}
|
||||||
|
onBlur={() => {}}
|
||||||
|
/>
|
||||||
|
)));
|
||||||
|
|
||||||
|
const RootWrapper = () => (
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<SettingCard
|
||||||
|
intl={{}}
|
||||||
|
isOn
|
||||||
|
name="settingName"
|
||||||
|
setEdited={setEdited}
|
||||||
|
setIsEditableState={setIsEditableState}
|
||||||
|
showSaveSettingsPrompt={showSaveSettingsPrompt}
|
||||||
|
settingData={settingData}
|
||||||
|
handleBlur={handleBlur}
|
||||||
|
isEditableState
|
||||||
|
saveSettingsPrompt={false}
|
||||||
|
/>
|
||||||
|
</IntlProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('<SettingCard />', () => {
|
||||||
|
afterEach(() => jest.clearAllMocks());
|
||||||
|
it('renders the setting card with the provided data', () => {
|
||||||
|
const { getByText, getByLabelText } = render(<RootWrapper />);
|
||||||
|
const cardTitle = getByText(/Setting Name/i);
|
||||||
|
const input = getByLabelText(/Setting Name/i);
|
||||||
|
expect(cardTitle).toBeInTheDocument();
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
expect(input.value).toBe(JSON.stringify(settingData.value, null, 4));
|
||||||
|
});
|
||||||
|
it('displays the deprecated status when the setting is deprecated', () => {
|
||||||
|
const deprecatedSettingData = { ...settingData, deprecated: true };
|
||||||
|
const { getByText } = render(
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<SettingCard
|
||||||
|
intl={{}}
|
||||||
|
isOn
|
||||||
|
name="settingName"
|
||||||
|
setEdited={setEdited}
|
||||||
|
setIsEditableState={setIsEditableState}
|
||||||
|
showSaveSettingsPrompt={showSaveSettingsPrompt}
|
||||||
|
settingData={deprecatedSettingData}
|
||||||
|
handleBlur={handleBlur}
|
||||||
|
isEditable={false}
|
||||||
|
saveSettingsPrompt
|
||||||
|
/>
|
||||||
|
</IntlProvider>,
|
||||||
|
);
|
||||||
|
const deprecatedStatus = getByText(messages.deprecated.defaultMessage);
|
||||||
|
expect(deprecatedStatus).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('does not display the deprecated status when the setting is not deprecated', () => {
|
||||||
|
const { queryByText } = render(<RootWrapper />);
|
||||||
|
expect(queryByText(messages.deprecated.defaultMessage)).toBeNull();
|
||||||
|
});
|
||||||
|
it('calls setEdited on blur', async () => {
|
||||||
|
const { getByLabelText } = render(<RootWrapper />);
|
||||||
|
const inputBox = getByLabelText(/Setting Name/i);
|
||||||
|
fireEvent.focus(inputBox);
|
||||||
|
userEvent.clear(inputBox);
|
||||||
|
userEvent.type(inputBox, '3, 2, 1');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(inputBox).toHaveValue('3, 2, 1');
|
||||||
|
});
|
||||||
|
await (async () => {
|
||||||
|
expect(setEdited).toHaveBeenCalled();
|
||||||
|
expect(handleBlur).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
fireEvent.focusOut(inputBox);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/advanced-settings/setting-card/messages.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
deprecated: {
|
||||||
|
id: 'course-authoring.advanced-settings.button.deprecated',
|
||||||
|
defaultMessage: 'Deprecated',
|
||||||
|
},
|
||||||
|
helpButtonText: {
|
||||||
|
id: 'course-authoring.advanced-settings.button.help',
|
||||||
|
defaultMessage: 'Show help text',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default messages;
|
||||||
47
src/advanced-settings/settings-sidebar/SettingsSidebar.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
FormattedMessage,
|
||||||
|
injectIntl,
|
||||||
|
intlShape,
|
||||||
|
} from '@edx/frontend-platform/i18n';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { HelpSidebar } from '../../generic/help-sidebar';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => (
|
||||||
|
<HelpSidebar
|
||||||
|
courseId={courseId}
|
||||||
|
proctoredExamSettingsUrl={proctoredExamSettingsUrl}
|
||||||
|
showOtherSettings
|
||||||
|
>
|
||||||
|
<h4 className="help-sidebar-about-title">
|
||||||
|
{intl.formatMessage(messages.about)}
|
||||||
|
</h4>
|
||||||
|
<p className="help-sidebar-about-descriptions">
|
||||||
|
{intl.formatMessage(messages.aboutDescription1)}
|
||||||
|
</p>
|
||||||
|
<p className="help-sidebar-about-descriptions">
|
||||||
|
{intl.formatMessage(messages.aboutDescription2)}
|
||||||
|
</p>
|
||||||
|
<p className="help-sidebar-about-descriptions">
|
||||||
|
<FormattedMessage
|
||||||
|
id="course-authoring.advanced-settings.about.description-3"
|
||||||
|
defaultMessage="{notice} When you enter strings as policy values, ensure that you use double quotation marks (“) around the string. Do not use single quotation marks (‘)."
|
||||||
|
values={{ notice: <strong>Note:</strong> }}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</HelpSidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
SettingsSidebar.defaultProps = {
|
||||||
|
proctoredExamSettingsUrl: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
SettingsSidebar.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
courseId: PropTypes.string.isRequired,
|
||||||
|
proctoredExamSettingsUrl: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(SettingsSidebar);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
import { initializeMockApp } from '@edx/frontend-platform';
|
||||||
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
|
|
||||||
|
import initializeStore from '../../store';
|
||||||
|
import SettingsSidebar from './SettingsSidebar';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
const courseId = 'course-123';
|
||||||
|
let store;
|
||||||
|
|
||||||
|
const RootWrapper = () => (
|
||||||
|
<AppProvider store={store}>
|
||||||
|
<IntlProvider locale="en" messages={{}}>
|
||||||
|
<SettingsSidebar intl={{ formatMessage: jest.fn() }} courseId={courseId} />
|
||||||
|
</IntlProvider>
|
||||||
|
</AppProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('<SettingsSidebar />', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
initializeMockApp({
|
||||||
|
authenticatedUser: {
|
||||||
|
userId: 3,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: true,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store = initializeStore();
|
||||||
|
});
|
||||||
|
it('renders about and other sidebar titles correctly', () => {
|
||||||
|
const { getByText } = render(<RootWrapper />);
|
||||||
|
expect(getByText(messages.about.defaultMessage)).toBeInTheDocument();
|
||||||
|
expect(getByText(messages.other.defaultMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('renders about descriptions correctly', () => {
|
||||||
|
const { getByText } = render(<RootWrapper />);
|
||||||
|
const aboutThirtyDescription = getByText('When you enter strings as policy values, ensure that you use double quotation marks (“) around the string. Do not use single quotation marks (‘).');
|
||||||
|
expect(getByText(messages.aboutDescription1.defaultMessage)).toBeInTheDocument();
|
||||||
|
expect(getByText(messages.aboutDescription2.defaultMessage)).toBeInTheDocument();
|
||||||
|
expect(aboutThirtyDescription).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
47
src/advanced-settings/settings-sidebar/messages.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
about: {
|
||||||
|
id: 'course-authoring.advanced-settings.sidebar.about.title',
|
||||||
|
defaultMessage: 'What do advanced settings do?',
|
||||||
|
},
|
||||||
|
aboutDescription1: {
|
||||||
|
id: 'course-authoring.advanced-settings.sidebar.about.description-1',
|
||||||
|
defaultMessage: 'Advanced settings control specific course functionality. On this page, you can edit manual policies, which are JSON-based key and value pairs that control specific course settings.',
|
||||||
|
},
|
||||||
|
aboutDescription2: {
|
||||||
|
id: 'course-authoring.advanced-settings.sidebar.about.description-2',
|
||||||
|
defaultMessage: 'Any policies you modify here override all other information you’ve defined elsewhere in Studio. Do not edit policies unless you are familiar with both their purpose and syntax.',
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
id: 'course-authoring.advanced-settings.sidebar.other.title',
|
||||||
|
defaultMessage: 'Other course settings',
|
||||||
|
},
|
||||||
|
otherCourseSettingsLinkToScheduleAndDetails: {
|
||||||
|
id: 'course-authoring.advanced-settings.sidebar.links.schedule-and-details',
|
||||||
|
defaultMessage: 'Details & schedule',
|
||||||
|
description: 'Link to Studio Details & schedule page',
|
||||||
|
},
|
||||||
|
otherCourseSettingsLinkToGrading: {
|
||||||
|
id: 'course-authoring.advanced-settings.sidebar.links.grading',
|
||||||
|
defaultMessage: 'Grading',
|
||||||
|
description: 'Link to Studio Grading page',
|
||||||
|
},
|
||||||
|
otherCourseSettingsLinkToCourseTeam: {
|
||||||
|
id: 'course-authoring.advanced-settings.sidebar.links.course-team',
|
||||||
|
defaultMessage: 'Course team',
|
||||||
|
description: 'Link to Studio Course team page',
|
||||||
|
},
|
||||||
|
otherCourseSettingsLinkToGroupConfigurations: {
|
||||||
|
id: 'course-authoring.advanced-settings.sidebar.links.group-configurations',
|
||||||
|
defaultMessage: 'Group configurations',
|
||||||
|
description: 'Link to Studio Group configurations page',
|
||||||
|
},
|
||||||
|
otherCourseSettingsLinkToProctoredExamSettings: {
|
||||||
|
id: 'course-authoring.advanced-settings.sidebar.links.proctored-exam-settings',
|
||||||
|
defaultMessage: 'Proctored exam settings',
|
||||||
|
description: 'Link to Proctored exam settings page',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default messages;
|
||||||
48
src/advanced-settings/utils.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Validates advanced settings data by checking if the provided settings are correctly formatted JSON.
|
||||||
|
* It performs validation on a given object of settings, detects incorrectly formatted settings,
|
||||||
|
* and sets error fields accordingly using the setErrorFields function.
|
||||||
|
*
|
||||||
|
* @param {object} settingObj - The object containing the settings to validate.
|
||||||
|
* @param {function} setErrorFields - The function to set error fields.
|
||||||
|
* @returns {boolean} - `true` if the data is valid, otherwise `false`.
|
||||||
|
*/
|
||||||
|
export default function validateAdvancedSettingsData(settingObj, setErrorFields, setEditedSettings) {
|
||||||
|
const fieldsWithErrors = [];
|
||||||
|
|
||||||
|
const pushDataToErrorArray = (settingName) => {
|
||||||
|
fieldsWithErrors.push({ key: settingName, message: 'Incorrectly formatted JSON' });
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(settingObj).forEach(([settingName, settingValue]) => {
|
||||||
|
try {
|
||||||
|
JSON.parse(settingValue);
|
||||||
|
} catch (e) {
|
||||||
|
let targetSettingValue = settingValue;
|
||||||
|
const firstNonWhite = settingValue.substring(0, 1);
|
||||||
|
const isValid = !['{', '[', "'"].includes(firstNonWhite);
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
try {
|
||||||
|
targetSettingValue = `"${ targetSettingValue.trim() }"`;
|
||||||
|
JSON.parse(targetSettingValue);
|
||||||
|
setEditedSettings((prevEditedSettings) => ({
|
||||||
|
...prevEditedSettings,
|
||||||
|
[settingName]: targetSettingValue,
|
||||||
|
}));
|
||||||
|
} catch (quotedE) { /* empty */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
pushDataToErrorArray(settingName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setErrorFields((prevState) => {
|
||||||
|
if (JSON.stringify(prevState) !== JSON.stringify(fieldsWithErrors)) {
|
||||||
|
return fieldsWithErrors;
|
||||||
|
}
|
||||||
|
return prevState;
|
||||||
|
});
|
||||||
|
|
||||||
|
return fieldsWithErrors.length === 0;
|
||||||
|
}
|
||||||
29
src/advanced-settings/utils.test.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import validateAdvancedSettingsData from './utils';
|
||||||
|
|
||||||
|
describe('validateAdvancedSettingsData', () => {
|
||||||
|
it('should validate correctly formatted settings and return true', () => {
|
||||||
|
const settingObj = {
|
||||||
|
setting1: '{ "key": "value" }',
|
||||||
|
setting2: '{ "key": "value" }',
|
||||||
|
};
|
||||||
|
const setErrorFieldsMock = jest.fn();
|
||||||
|
const setEditedSettingsMock = jest.fn();
|
||||||
|
const isValid = validateAdvancedSettingsData(settingObj, setErrorFieldsMock, setEditedSettingsMock);
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
expect(setErrorFieldsMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setEditedSettingsMock).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
it('should validate incorrectly formatted settings and set error fields', () => {
|
||||||
|
const settingObj = {
|
||||||
|
setting1: '{ "key": "value" }',
|
||||||
|
setting2: 'incorrectJSON',
|
||||||
|
setting3: '{ "key": "value" }',
|
||||||
|
};
|
||||||
|
const setErrorFieldsMock = jest.fn();
|
||||||
|
const setEditedSettingsMock = jest.fn();
|
||||||
|
const isValid = validateAdvancedSettingsData(settingObj, setErrorFieldsMock, setEditedSettingsMock);
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
expect(setErrorFieldsMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setEditedSettingsMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
9
src/assets/scss/_animations.scss
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@keyframes rotate {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/assets/scss/_form.scss
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
.form-group-custom {
|
||||||
|
.pgn__form-label {
|
||||||
|
font: normal $font-weight-bold .75rem/1.25rem $font-family-base;
|
||||||
|
color: $gray-500;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pgn__form-control-description,
|
||||||
|
.pgn__form-text {
|
||||||
|
font: normal $font-weight-normal .75rem/1.25rem $font-family-base;
|
||||||
|
color: $gray-500;
|
||||||
|
margin-top: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-toggle {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-custom_isInvalid {
|
||||||
|
input {
|
||||||
|
border-color: $form-feedback-invalid-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-error {
|
||||||
|
color: $form-feedback-invalid-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-custom {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
.datepicker-custom-control {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
font-size: $input-font-size;
|
||||||
|
font-weight: $input-font-weight;
|
||||||
|
line-height: $input-line-height;
|
||||||
|
background: $input-bg;
|
||||||
|
border-color: $input-border-color;
|
||||||
|
border-width: $input-border-width;
|
||||||
|
box-shadow: $input-box-shadow;
|
||||||
|
border-radius: $input-border-radius;
|
||||||
|
color: $input-color;
|
||||||
|
padding: $input-padding-y $input-padding-x;
|
||||||
|
height: $input-height;
|
||||||
|
resize: none;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
:focus-visible {
|
||||||
|
color: $input-focus-color;
|
||||||
|
background-color: $input-bg;
|
||||||
|
border-color: $input-focus-border-color;
|
||||||
|
box-shadow: $input-focus-box-shadow;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $input-placeholder-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-custom-control_readonly {
|
||||||
|
border-color: transparent;
|
||||||
|
background: $input-disabled-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-custom-control_isInvalid {
|
||||||
|
border-color: $form-feedback-invalid-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-custom-control-icon {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
right: 1.188rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: $black;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/assets/scss/_utilities.scss
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.text-black {
|
||||||
|
color: $black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-200px {
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mw-300px {
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
2
src/assets/scss/_variables.scss
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
$text-color-base: $gray-700;
|
||||||
|
$text-color-weak: #3E3E3C;
|
||||||
47
src/constants.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export const DATE_FORMAT = 'MM/dd/yyyy';
|
||||||
|
export const TIME_FORMAT = 'HH:mm';
|
||||||
|
export const DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss\\Z';
|
||||||
|
export const COMMA_SEPARATED_DATE_FORMAT = 'MMMM D, YYYY';
|
||||||
|
export const DEFAULT_EMPTY_WYSIWYG_VALUE = '<p> </p>';
|
||||||
|
export const STATEFUL_BUTTON_STATES = {
|
||||||
|
default: 'default',
|
||||||
|
pending: 'pending',
|
||||||
|
error: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const USER_ROLES = {
|
||||||
|
admin: 'instructor',
|
||||||
|
staff: 'staff',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BADGE_STATES = {
|
||||||
|
danger: 'danger',
|
||||||
|
secondary: 'secondary',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NOTIFICATION_MESSAGES = {
|
||||||
|
adding: 'Adding',
|
||||||
|
saving: 'Saving',
|
||||||
|
duplicating: 'Duplicating',
|
||||||
|
deleting: 'Deleting',
|
||||||
|
copying: 'Copying',
|
||||||
|
pasting: 'Pasting',
|
||||||
|
empty: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_TIME_STAMP = '00:00';
|
||||||
|
|
||||||
|
export const COURSE_CREATOR_STATES = {
|
||||||
|
unrequested: 'unrequested',
|
||||||
|
pending: 'pending',
|
||||||
|
granted: 'granted',
|
||||||
|
denied: 'denied',
|
||||||
|
disallowedForThisSite: 'disallowed_for_this_site',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DECODED_ROUTES = {
|
||||||
|
COURSE_UNIT: [
|
||||||
|
'/container/:blockId/:sequenceId',
|
||||||
|
'/container/:blockId',
|
||||||
|
],
|
||||||
|
};
|
||||||
223
src/content-tags-drawer/ContentTagsCollapsible.jsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
// @ts-check
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Collapsible,
|
||||||
|
SelectableBox,
|
||||||
|
Button,
|
||||||
|
ModalPopup,
|
||||||
|
useToggle,
|
||||||
|
SearchField,
|
||||||
|
} from '@edx/paragon';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
import messages from './messages';
|
||||||
|
import './ContentTagsCollapsible.scss';
|
||||||
|
|
||||||
|
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
|
||||||
|
|
||||||
|
import ContentTagsTree from './ContentTagsTree';
|
||||||
|
|
||||||
|
import useContentTagsCollapsibleHelper from './ContentTagsCollapsibleHelper';
|
||||||
|
|
||||||
|
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
|
||||||
|
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapsible component that holds a Taxonomy along with Tags that belong to it.
|
||||||
|
* This includes both applied tags and tags that are available to select
|
||||||
|
* from a dropdown list.
|
||||||
|
*
|
||||||
|
* This component also handles all the logic with selecting/deselecting tags and keeps track of the
|
||||||
|
* tags tree in the state. That is used to render the Tag bubbgles as well as the populating the
|
||||||
|
* state of the tags in the dropdown selectors.
|
||||||
|
*
|
||||||
|
* The `contentTags` that is passed are consolidated and converted to a tree structure. For example:
|
||||||
|
*
|
||||||
|
* FROM:
|
||||||
|
*
|
||||||
|
* [
|
||||||
|
* {
|
||||||
|
* "value": "DNA Sequencing",
|
||||||
|
* "lineage": [
|
||||||
|
* "Science and Research",
|
||||||
|
* "Genetics Subcategory",
|
||||||
|
* "DNA Sequencing"
|
||||||
|
* ]
|
||||||
|
* },
|
||||||
|
* {
|
||||||
|
* "value": "Virology",
|
||||||
|
* "lineage": [
|
||||||
|
* "Science and Research",
|
||||||
|
* "Molecular, Cellular, and Microbiology",
|
||||||
|
* "Virology"
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
*
|
||||||
|
* TO:
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* "Science and Research": {
|
||||||
|
* explicit: false,
|
||||||
|
* children: {
|
||||||
|
* "Genetics Subcategory": {
|
||||||
|
* explicit: false,
|
||||||
|
* children: {
|
||||||
|
* "DNA Sequencing": {
|
||||||
|
* explicit: true,
|
||||||
|
* children: {}
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* },
|
||||||
|
* "Molecular, Cellular, and Microbiology": {
|
||||||
|
* explicit: false,
|
||||||
|
* children: {
|
||||||
|
* "Virology": {
|
||||||
|
* explicit: true,
|
||||||
|
* children: {}
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* It also keeps track of newly added tags as they are selected in the dropdown selectors.
|
||||||
|
* They are store in the same format above, and then merged to one tree that is used as the
|
||||||
|
* source of truth for both the tag bubble and the dropdowns. They keys are order alphabetically.
|
||||||
|
*
|
||||||
|
* In the dropdowns, the value of each SelectableBox is stored along with it's lineage and is URI encoded.
|
||||||
|
* Ths is so we are able to traverse and manipulate different parts of the tree leading to it.
|
||||||
|
* Here is an example of what the value of the "Virology" tag would be:
|
||||||
|
*
|
||||||
|
* "Science%20and%20Research,Molecular%2C%20Cellular%2C%20and%20Microbiology,Virology"
|
||||||
|
*
|
||||||
|
* @param {Object} props - The component props.
|
||||||
|
* @param {string} props.contentId - Id of the content object
|
||||||
|
* @param {TaxonomyData & {contentTags: ContentTagData[]}} props.taxonomyAndTagsData - Taxonomy metadata & applied tags
|
||||||
|
*/
|
||||||
|
const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { id, name, canTagObject } = taxonomyAndTagsData;
|
||||||
|
|
||||||
|
const {
|
||||||
|
tagChangeHandler, tagsTree, contentTagsCount, checkedTags,
|
||||||
|
} = useContentTagsCollapsibleHelper(contentId, taxonomyAndTagsData);
|
||||||
|
|
||||||
|
const [isOpen, open, close] = useToggle(false);
|
||||||
|
const [addTagsButtonRef, setAddTagsButtonRef] = React.useState(null);
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = React.useState('');
|
||||||
|
|
||||||
|
const handleSelectableBoxChange = React.useCallback((e) => {
|
||||||
|
tagChangeHandler(e.target.value, e.target.checked);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearch = debounce((term) => {
|
||||||
|
setSearchTerm(term.trim());
|
||||||
|
}, 500); // Perform search after 500ms
|
||||||
|
|
||||||
|
const handleSearchChange = React.useCallback((value) => {
|
||||||
|
if (value === '') {
|
||||||
|
// No need to debounce when search term cleared. Clear debounce function
|
||||||
|
handleSearch.cancel();
|
||||||
|
setSearchTerm('');
|
||||||
|
} else {
|
||||||
|
handleSearch(value);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const modalPopupOnCloseHandler = React.useCallback((event) => {
|
||||||
|
close(event);
|
||||||
|
// Clear search term
|
||||||
|
setSearchTerm('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="d-flex">
|
||||||
|
<Collapsible title={name} styling="card-lg" className="taxonomy-tags-collapsible">
|
||||||
|
<div key={id}>
|
||||||
|
<ContentTagsTree tagsTree={tagsTree} removeTagHandler={tagChangeHandler} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex taxonomy-tags-selector-menu">
|
||||||
|
|
||||||
|
{canTagObject && (
|
||||||
|
<Button
|
||||||
|
ref={setAddTagsButtonRef}
|
||||||
|
variant="outline-primary"
|
||||||
|
onClick={open}
|
||||||
|
>
|
||||||
|
<FormattedMessage {...messages.addTagsButtonText} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ModalPopup
|
||||||
|
hasArrow
|
||||||
|
placement="bottom"
|
||||||
|
positionRef={addTagsButtonRef}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={modalPopupOnCloseHandler}
|
||||||
|
>
|
||||||
|
<div className="bg-white p-3 shadow">
|
||||||
|
|
||||||
|
<SelectableBox.Set
|
||||||
|
type="checkbox"
|
||||||
|
name="tags"
|
||||||
|
columns={1}
|
||||||
|
ariaLabel={intl.formatMessage(messages.taxonomyTagsAriaLabel)}
|
||||||
|
className="taxonomy-tags-selectable-box-set"
|
||||||
|
onChange={handleSelectableBoxChange}
|
||||||
|
value={checkedTags}
|
||||||
|
>
|
||||||
|
<SearchField
|
||||||
|
onSubmit={() => {}}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
className="mb-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ContentTagsDropDownSelector
|
||||||
|
key={`selector-${id}`}
|
||||||
|
taxonomyId={id}
|
||||||
|
level={0}
|
||||||
|
tagsTree={tagsTree}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
/>
|
||||||
|
</SelectableBox.Set>
|
||||||
|
</div>
|
||||||
|
</ModalPopup>
|
||||||
|
|
||||||
|
</Collapsible>
|
||||||
|
<div className="d-flex">
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
pill
|
||||||
|
className={classNames('align-self-start', 'mt-3', {
|
||||||
|
invisible: contentTagsCount === 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{contentTagsCount}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ContentTagsCollapsible.propTypes = {
|
||||||
|
contentId: PropTypes.string.isRequired,
|
||||||
|
taxonomyAndTagsData: PropTypes.shape({
|
||||||
|
id: PropTypes.number,
|
||||||
|
name: PropTypes.string,
|
||||||
|
contentTags: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
value: PropTypes.string,
|
||||||
|
lineage: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
})),
|
||||||
|
canTagObject: PropTypes.bool.isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContentTagsCollapsible;
|
||||||
29
src/content-tags-drawer/ContentTagsCollapsible.scss
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
.taxonomy-tags-collapsible {
|
||||||
|
flex: 1;
|
||||||
|
border: none !important;
|
||||||
|
|
||||||
|
.collapsible-trigger {
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-tags-selector-menu {
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-tags-selector-menu + div {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-tags-selectable-box-set {
|
||||||
|
grid-auto-rows: unset !important;
|
||||||
|
grid-gap: unset !important;
|
||||||
|
overflow-y: scroll;
|
||||||
|
max-height: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pgn__modal-popup__arrow {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
262
src/content-tags-drawer/ContentTagsCollapsible.test.jsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
import {
|
||||||
|
act,
|
||||||
|
render,
|
||||||
|
fireEvent,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import ContentTagsCollapsible from './ContentTagsCollapsible';
|
||||||
|
import messages from './messages';
|
||||||
|
import { useTaxonomyTagsData } from './data/apiHooks';
|
||||||
|
|
||||||
|
jest.mock('./data/apiHooks', () => ({
|
||||||
|
useContentTaxonomyTagsUpdater: jest.fn(() => ({
|
||||||
|
isError: false,
|
||||||
|
mutate: jest.fn(),
|
||||||
|
})),
|
||||||
|
useTaxonomyTagsData: jest.fn(() => ({
|
||||||
|
hasMorePages: false,
|
||||||
|
tagPages: {
|
||||||
|
isLoading: true,
|
||||||
|
isError: false,
|
||||||
|
canAddTag: false,
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
|
||||||
|
taxonomyAndTagsData: {
|
||||||
|
id: 123,
|
||||||
|
name: 'Taxonomy 1',
|
||||||
|
canTagObject: true,
|
||||||
|
contentTags: [
|
||||||
|
{
|
||||||
|
value: 'Tag 1',
|
||||||
|
lineage: ['Tag 1'],
|
||||||
|
canDeleteObjecttag: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'Tag 1.1',
|
||||||
|
lineage: ['Tag 1', 'Tag 1.1'],
|
||||||
|
canDeleteObjecttag: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'Tag 2',
|
||||||
|
lineage: ['Tag 2'],
|
||||||
|
canDeleteObjecttag: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContentTagsCollapsibleComponent = ({ contentId, taxonomyAndTagsData }) => (
|
||||||
|
<IntlProvider locale="en" messages={{}}>
|
||||||
|
<ContentTagsCollapsible contentId={contentId} taxonomyAndTagsData={taxonomyAndTagsData} />
|
||||||
|
</IntlProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
ContentTagsCollapsibleComponent.propTypes = ContentTagsCollapsible.propTypes;
|
||||||
|
|
||||||
|
describe('<ContentTagsCollapsible />', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
jest.useFakeTimers(); // To account for debounce timer
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.useRealTimers(); // Restore real timers after the tests
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getComponent(updatedData) {
|
||||||
|
const componentData = (!updatedData ? data : updatedData);
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<ContentTagsCollapsibleComponent
|
||||||
|
contentId={componentData.contentId}
|
||||||
|
taxonomyAndTagsData={componentData.taxonomyAndTagsData}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupTaxonomyMock() {
|
||||||
|
useTaxonomyTagsData.mockReturnValue({
|
||||||
|
hasMorePages: false,
|
||||||
|
canAddTag: false,
|
||||||
|
tagPages: {
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
data: [{
|
||||||
|
value: 'Tag 1',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 0,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 12345,
|
||||||
|
subTagsUrl: null,
|
||||||
|
canChangeTag: false,
|
||||||
|
canDeleteTag: false,
|
||||||
|
}, {
|
||||||
|
value: 'Tag 2',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 0,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 12346,
|
||||||
|
subTagsUrl: null,
|
||||||
|
canChangeTag: false,
|
||||||
|
canDeleteTag: false,
|
||||||
|
}, {
|
||||||
|
value: 'Tag 3',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 0,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 12347,
|
||||||
|
subTagsUrl: null,
|
||||||
|
canChangeTag: false,
|
||||||
|
canDeleteTag: false,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should render taxonomy tags data along content tags number badge', async () => {
|
||||||
|
const { container, getByText } = await getComponent();
|
||||||
|
expect(getByText('Taxonomy 1')).toBeInTheDocument();
|
||||||
|
expect(container.getElementsByClassName('badge').length).toBe(1);
|
||||||
|
expect(getByText('3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render new tags as they are checked in the dropdown', async () => {
|
||||||
|
setupTaxonomyMock();
|
||||||
|
const { container, getByText, getAllByText } = await getComponent();
|
||||||
|
|
||||||
|
// Expand the Taxonomy to view applied tags and "Add tags" button
|
||||||
|
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||||
|
fireEvent.click(expandToggle);
|
||||||
|
|
||||||
|
// Click on "Add tags" button to open dropdown to select new tags
|
||||||
|
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
|
||||||
|
fireEvent.click(addTagsButton);
|
||||||
|
|
||||||
|
// Wait for the dropdown selector for tags to open,
|
||||||
|
// Tag 3 should only appear there
|
||||||
|
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||||
|
expect(getAllByText('Tag 3').length === 1);
|
||||||
|
|
||||||
|
const tag3 = getByText('Tag 3');
|
||||||
|
|
||||||
|
fireEvent.click(tag3);
|
||||||
|
|
||||||
|
// After clicking on Tag 3, it should also appear in amongst
|
||||||
|
// the tag bubbles in the tree
|
||||||
|
expect(getAllByText('Tag 3').length === 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove tag when they are unchecked in the dropdown', async () => {
|
||||||
|
setupTaxonomyMock();
|
||||||
|
const { container, getByText, getAllByText } = await getComponent();
|
||||||
|
|
||||||
|
// Expand the Taxonomy to view applied tags and "Add tags" button
|
||||||
|
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||||
|
|
||||||
|
fireEvent.click(expandToggle);
|
||||||
|
|
||||||
|
// Check that Tag 2 appears in tag bubbles
|
||||||
|
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click on "Add tags" button to open dropdown to select new tags
|
||||||
|
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
|
||||||
|
fireEvent.click(addTagsButton);
|
||||||
|
|
||||||
|
// Wait for the dropdown selector for tags to open,
|
||||||
|
// Tag 3 should only appear there, (i.e. the dropdown is open, since Tag 3 is not applied)
|
||||||
|
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Get the Tag 2 checkbox and click on it
|
||||||
|
const tag2 = getAllByText('Tag 2')[1];
|
||||||
|
fireEvent.click(tag2);
|
||||||
|
|
||||||
|
// After clicking on Tag 2, it should be removed from
|
||||||
|
// the tag bubbles in so only the one in the dropdown appears
|
||||||
|
expect(getAllByText('Tag 2').length === 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle search term change', async () => {
|
||||||
|
const {
|
||||||
|
container, getByText, getByRole, getByDisplayValue,
|
||||||
|
} = await getComponent();
|
||||||
|
|
||||||
|
// Expand the Taxonomy to view applied tags and "Add tags" button
|
||||||
|
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||||
|
fireEvent.click(expandToggle);
|
||||||
|
|
||||||
|
// Click on "Add tags" button to open dropdown
|
||||||
|
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
|
||||||
|
fireEvent.click(addTagsButton);
|
||||||
|
|
||||||
|
// Get the search field
|
||||||
|
const searchField = getByRole('searchbox');
|
||||||
|
|
||||||
|
const searchTerm = 'memo';
|
||||||
|
|
||||||
|
// Trigger a change in the search field
|
||||||
|
userEvent.type(searchField, searchTerm);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
// Fast-forward time by 500 milliseconds (for the debounce delay)
|
||||||
|
jest.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that the search term has been set
|
||||||
|
expect(searchField).toHaveValue(searchTerm);
|
||||||
|
expect(getByDisplayValue(searchTerm)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Clear search
|
||||||
|
userEvent.clear(searchField);
|
||||||
|
|
||||||
|
// Check that the search term has been cleared
|
||||||
|
expect(searchField).toHaveValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close dropdown selector when clicking away', async () => {
|
||||||
|
setupTaxonomyMock();
|
||||||
|
const { container, getByText, queryByText } = await getComponent();
|
||||||
|
|
||||||
|
// Expand the Taxonomy to view applied tags and "Add tags" button
|
||||||
|
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||||
|
|
||||||
|
fireEvent.click(expandToggle);
|
||||||
|
|
||||||
|
// Click on "Add tags" button to open dropdown
|
||||||
|
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
|
||||||
|
fireEvent.click(addTagsButton);
|
||||||
|
|
||||||
|
// Wait for the dropdown selector for tags to open, Tag 3 should appear
|
||||||
|
// since it is not applied
|
||||||
|
expect(queryByText('Tag 3')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Simulate clicking outside the dropdown remove focus
|
||||||
|
userEvent.click(document.body);
|
||||||
|
|
||||||
|
// Simulate clicking outside the dropdown again to close it
|
||||||
|
userEvent.click(document.body);
|
||||||
|
|
||||||
|
// Wait for the dropdown selector for tags to close, Tag 3 is no longer on
|
||||||
|
// the page
|
||||||
|
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render taxonomy tags data without tags number badge', async () => {
|
||||||
|
const updatedData = { ...data };
|
||||||
|
updatedData.taxonomyAndTagsData = { ...updatedData.taxonomyAndTagsData };
|
||||||
|
updatedData.taxonomyAndTagsData.contentTags = [];
|
||||||
|
const { container, getByText } = await getComponent(updatedData);
|
||||||
|
|
||||||
|
expect(getByText('Taxonomy 1')).toBeInTheDocument();
|
||||||
|
expect(container.getElementsByClassName('invisible').length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
214
src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
// @ts-check
|
||||||
|
import React from 'react';
|
||||||
|
import { useCheckboxSetValues } from '@edx/paragon';
|
||||||
|
import { cloneDeep } from 'lodash';
|
||||||
|
|
||||||
|
import { useContentTaxonomyTagsUpdater } from './data/apiHooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Util function that consolidates two tag trees into one, sorting the keys in
|
||||||
|
* alphabetical order.
|
||||||
|
*
|
||||||
|
* @param {object} tree1 - first tag tree
|
||||||
|
* @param {object} tree2 - second tag tree
|
||||||
|
* @returns {object} merged tree containing both tree1 and tree2
|
||||||
|
*/
|
||||||
|
const mergeTrees = (tree1, tree2) => {
|
||||||
|
const mergedTree = cloneDeep(tree1);
|
||||||
|
|
||||||
|
const sortKeysAlphabetically = (obj) => {
|
||||||
|
const sortedObj = {};
|
||||||
|
Object.keys(obj)
|
||||||
|
.sort()
|
||||||
|
.forEach((key) => {
|
||||||
|
sortedObj[key] = obj[key];
|
||||||
|
if (obj[key] && typeof obj[key] === 'object') {
|
||||||
|
sortedObj[key].children = sortKeysAlphabetically(obj[key].children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return sortedObj;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeRecursively = (destination, source) => {
|
||||||
|
Object.entries(source).forEach(([key, sourceValue]) => {
|
||||||
|
const destinationValue = destination[key];
|
||||||
|
|
||||||
|
if (destinationValue && sourceValue && typeof destinationValue === 'object' && typeof sourceValue === 'object') {
|
||||||
|
mergeRecursively(destinationValue, sourceValue);
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
destination[key] = cloneDeep(sourceValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
mergeRecursively(mergedTree, tree2);
|
||||||
|
return sortKeysAlphabetically(mergedTree);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Util function that removes the tag along with its ancestors if it was
|
||||||
|
* the only explicit child tag.
|
||||||
|
*
|
||||||
|
* @param {object} tree - tag tree to remove the tag from
|
||||||
|
* @param {string[]} tagsToRemove - full lineage of tag to remove.
|
||||||
|
* eg: ['grand parent', 'parent', 'tag']
|
||||||
|
*/
|
||||||
|
const removeTags = (tree, tagsToRemove) => {
|
||||||
|
if (!tree || !tagsToRemove.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = tagsToRemove[0];
|
||||||
|
if (tree[key]) {
|
||||||
|
removeTags(tree[key].children, tagsToRemove.slice(1));
|
||||||
|
|
||||||
|
if (Object.keys(tree[key].children).length === 0 && (tree[key].explicit === false || tagsToRemove.length === 1)) {
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
delete tree[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Handles all the underlying logic for the ContentTagsCollapsible component
|
||||||
|
*/
|
||||||
|
const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => {
|
||||||
|
const {
|
||||||
|
id, contentTags, canTagObject,
|
||||||
|
} = taxonomyAndTagsData;
|
||||||
|
// State to determine whether the tags are being updating so we can make a call
|
||||||
|
// to the update endpoint to the reflect those changes
|
||||||
|
const [updatingTags, setUpdatingTags] = React.useState(false);
|
||||||
|
const updateTags = useContentTaxonomyTagsUpdater(contentId, id);
|
||||||
|
|
||||||
|
// Keeps track of the content objects tags count (both implicit and explicit)
|
||||||
|
const [contentTagsCount, setContentTagsCount] = React.useState(0);
|
||||||
|
|
||||||
|
// Keeps track of the tree structure for tags that are add by selecting/unselecting
|
||||||
|
// tags in the dropdowns.
|
||||||
|
const [addedContentTags, setAddedContentTags] = React.useState({});
|
||||||
|
|
||||||
|
// To handle checking/unchecking tags in the SelectableBox
|
||||||
|
const [checkedTags, { add, remove, clear }] = useCheckboxSetValues();
|
||||||
|
|
||||||
|
// Handles making requests to the update endpoint whenever the checked tags change
|
||||||
|
React.useEffect(() => {
|
||||||
|
// We have this check because this hook is fired when the component first loads
|
||||||
|
// and reloads (on refocus). We only want to make a request to the update endpoint when
|
||||||
|
// the user is updating the tags.
|
||||||
|
if (updatingTags) {
|
||||||
|
setUpdatingTags(false);
|
||||||
|
const tags = checkedTags.map(t => decodeURIComponent(t.split(',').slice(-1)));
|
||||||
|
updateTags.mutate({ tags });
|
||||||
|
}
|
||||||
|
}, [contentId, id, canTagObject, checkedTags]);
|
||||||
|
|
||||||
|
// This converts the contentTags prop to the tree structure mentioned above
|
||||||
|
const appliedContentTags = React.useMemo(() => {
|
||||||
|
let contentTagsCounter = 0;
|
||||||
|
|
||||||
|
// Clear all the tags that have not been commited and the checked boxes when
|
||||||
|
// fresh contentTags passed in so the latest state from the backend is rendered
|
||||||
|
setAddedContentTags({});
|
||||||
|
clear();
|
||||||
|
|
||||||
|
// When an error occurs while updating, the contentTags query is invalidated,
|
||||||
|
// hence they will be recalculated, and the updateTags mutation should be reset.
|
||||||
|
if (updateTags.isError) {
|
||||||
|
updateTags.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultTree = {};
|
||||||
|
contentTags.forEach(item => {
|
||||||
|
let currentLevel = resultTree;
|
||||||
|
|
||||||
|
item.lineage.forEach((key, index) => {
|
||||||
|
if (!currentLevel[key]) {
|
||||||
|
const isExplicit = index === item.lineage.length - 1;
|
||||||
|
currentLevel[key] = {
|
||||||
|
explicit: isExplicit,
|
||||||
|
children: {},
|
||||||
|
canChangeObjecttag: item.canChangeObjecttag,
|
||||||
|
canDeleteObjecttag: item.canDeleteObjecttag,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Populating the SelectableBox with "selected" (explicit) tags
|
||||||
|
const value = item.lineage.map(l => encodeURIComponent(l)).join(',');
|
||||||
|
// eslint-disable-next-line no-unused-expressions
|
||||||
|
isExplicit ? add(value) : remove(value);
|
||||||
|
contentTagsCounter += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLevel = currentLevel[key].children;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setContentTagsCount(contentTagsCounter);
|
||||||
|
return resultTree;
|
||||||
|
}, [contentTags, updateTags.isError]);
|
||||||
|
|
||||||
|
// This is the source of truth that represents the current state of tags in
|
||||||
|
// this Taxonomy as a tree. Whenever either the `appliedContentTags` (i.e. tags passed in
|
||||||
|
// the prop from the backed) change, or when the `addedContentTags` (i.e. tags added by
|
||||||
|
// selecting/unselecting them in the dropdown) change, the tree is recomputed.
|
||||||
|
const tagsTree = React.useMemo(() => (
|
||||||
|
mergeTrees(appliedContentTags, addedContentTags)
|
||||||
|
), [appliedContentTags, addedContentTags]);
|
||||||
|
|
||||||
|
// Add tag to the tree, and while traversing remove any selected ancestor tags
|
||||||
|
// as they should become implicit
|
||||||
|
const addTags = (tree, tagLineage, selectedTag) => {
|
||||||
|
const value = [];
|
||||||
|
let traversal = tree;
|
||||||
|
tagLineage.forEach(tag => {
|
||||||
|
const isExplicit = selectedTag === tag;
|
||||||
|
|
||||||
|
if (!traversal[tag]) {
|
||||||
|
traversal[tag] = {
|
||||||
|
explicit: isExplicit,
|
||||||
|
children: {},
|
||||||
|
canChangeObjecttag: false,
|
||||||
|
canDeleteObjecttag: false,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
traversal[tag].explicit = isExplicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear out the ancestor tags leading to newly selected tag
|
||||||
|
// as they automatically become implicit
|
||||||
|
value.push(encodeURIComponent(tag));
|
||||||
|
// eslint-disable-next-line no-unused-expressions
|
||||||
|
isExplicit ? add(value.join(',')) : remove(value.join(','));
|
||||||
|
|
||||||
|
traversal = traversal[tag].children;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagChangeHandler = React.useCallback((tagSelectableBoxValue, checked) => {
|
||||||
|
const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t));
|
||||||
|
const selectedTag = tagLineage.slice(-1)[0];
|
||||||
|
|
||||||
|
const addedTree = { ...addedContentTags };
|
||||||
|
if (checked) {
|
||||||
|
// We "add" the tag to the SelectableBox.Set inside the addTags method
|
||||||
|
addTags(addedTree, tagLineage, selectedTag);
|
||||||
|
} else {
|
||||||
|
// Remove tag from the SelectableBox.Set
|
||||||
|
remove(tagSelectableBoxValue);
|
||||||
|
|
||||||
|
// We remove them from both incase we are unselecting from an
|
||||||
|
// existing applied Tag or a newly added one
|
||||||
|
removeTags(addedTree, tagLineage);
|
||||||
|
removeTags(appliedContentTags, tagLineage);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAddedContentTags(addedTree);
|
||||||
|
setUpdatingTags(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagChangeHandler, tagsTree, contentTagsCount, checkedTags,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useContentTagsCollapsibleHelper;
|
||||||
119
src/content-tags-drawer/ContentTagsDrawer.jsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// @ts-check
|
||||||
|
import React, { useMemo, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
CloseButton,
|
||||||
|
Spinner,
|
||||||
|
} from '@edx/paragon';
|
||||||
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import messages from './messages';
|
||||||
|
import ContentTagsCollapsible from './ContentTagsCollapsible';
|
||||||
|
import { extractOrgFromContentId } from './utils';
|
||||||
|
import {
|
||||||
|
useContentTaxonomyTagsData,
|
||||||
|
useContentData,
|
||||||
|
} from './data/apiHooks';
|
||||||
|
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
|
||||||
|
import Loading from '../generic/Loading';
|
||||||
|
|
||||||
|
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
|
||||||
|
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
|
||||||
|
|
||||||
|
const ContentTagsDrawer = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { contentId } = /** @type {{contentId: string}} */(useParams());
|
||||||
|
|
||||||
|
const org = extractOrgFromContentId(contentId);
|
||||||
|
|
||||||
|
const useTaxonomyListData = () => {
|
||||||
|
const taxonomyListData = useTaxonomyListDataResponse(org);
|
||||||
|
const isTaxonomyListLoaded = useIsTaxonomyListDataLoaded(org);
|
||||||
|
return { taxonomyListData, isTaxonomyListLoaded };
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId);
|
||||||
|
const {
|
||||||
|
data: contentTaxonomyTagsData,
|
||||||
|
isSuccess: isContentTaxonomyTagsLoaded,
|
||||||
|
} = useContentTaxonomyTagsData(contentId);
|
||||||
|
const { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData();
|
||||||
|
|
||||||
|
const closeContentTagsDrawer = () => {
|
||||||
|
// "*" allows communication with any origin
|
||||||
|
window.parent.postMessage('closeManageTagsDrawer', '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEsc = (event) => {
|
||||||
|
/* Close drawer when ESC-key is pressed and selectable dropdown box not open */
|
||||||
|
const selectableBoxOpen = document.querySelector('[data-selectable-box="taxonomy-tags"]');
|
||||||
|
if (event.key === 'Escape' && !selectableBoxOpen) {
|
||||||
|
closeContentTagsDrawer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleEsc);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEsc);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const taxonomies = useMemo(() => {
|
||||||
|
if (taxonomyListData && contentTaxonomyTagsData) {
|
||||||
|
// Initialize list of content tags in taxonomies to populate
|
||||||
|
const taxonomiesList = taxonomyListData.results.map((taxonomy) => ({
|
||||||
|
...taxonomy,
|
||||||
|
contentTags: /** @type {ContentTagData[]} */([]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const contentTaxonomies = contentTaxonomyTagsData.taxonomies;
|
||||||
|
|
||||||
|
// eslint-disable-next-line array-callback-return
|
||||||
|
contentTaxonomies.map((contentTaxonomyTags) => {
|
||||||
|
const contentTaxonomy = taxonomiesList.find((taxonomy) => taxonomy.id === contentTaxonomyTags.taxonomyId);
|
||||||
|
if (contentTaxonomy) {
|
||||||
|
contentTaxonomy.contentTags = contentTaxonomyTags.tags;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return taxonomiesList;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [taxonomyListData, contentTaxonomyTagsData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div className="mt-1">
|
||||||
|
<Container size="xl">
|
||||||
|
<CloseButton onClick={() => closeContentTagsDrawer()} data-testid="drawer-close-button" />
|
||||||
|
<span>{intl.formatMessage(messages.headerSubtitle)}</span>
|
||||||
|
{ isContentDataLoaded
|
||||||
|
? <h3>{ contentData.displayName }</h3>
|
||||||
|
: (
|
||||||
|
<div className="d-flex justify-content-center align-items-center flex-column">
|
||||||
|
<Spinner
|
||||||
|
animation="border"
|
||||||
|
size="xl"
|
||||||
|
screenReaderText={intl.formatMessage(messages.loadingMessage)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded
|
||||||
|
? taxonomies.map((data) => (
|
||||||
|
<div key={`taxonomy-tags-collapsible-${data.id}`}>
|
||||||
|
<ContentTagsCollapsible contentId={contentId} taxonomyAndTagsData={data} />
|
||||||
|
<hr />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: <Loading />}
|
||||||
|
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContentTagsDrawer;
|
||||||
190
src/content-tags-drawer/ContentTagsDrawer.test.jsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
import { act, render, fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
|
import ContentTagsDrawer from './ContentTagsDrawer';
|
||||||
|
import {
|
||||||
|
useContentTaxonomyTagsData,
|
||||||
|
useContentData,
|
||||||
|
} from './data/apiHooks';
|
||||||
|
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useParams: () => ({
|
||||||
|
contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./data/apiHooks', () => ({
|
||||||
|
useContentTaxonomyTagsData: jest.fn(() => ({
|
||||||
|
isSuccess: false,
|
||||||
|
data: {},
|
||||||
|
})),
|
||||||
|
useContentData: jest.fn(() => ({
|
||||||
|
isSuccess: false,
|
||||||
|
data: {},
|
||||||
|
})),
|
||||||
|
useContentTaxonomyTagsUpdater: jest.fn(() => ({
|
||||||
|
isError: false,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../taxonomy/data/apiHooks', () => ({
|
||||||
|
useTaxonomyListDataResponse: jest.fn(),
|
||||||
|
useIsTaxonomyListDataLoaded: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const RootWrapper = () => (
|
||||||
|
<IntlProvider locale="en" messages={{}}>
|
||||||
|
<ContentTagsDrawer />
|
||||||
|
</IntlProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('<ContentTagsDrawer />', () => {
|
||||||
|
it('should render page and page title correctly', () => {
|
||||||
|
const { getByText } = render(<RootWrapper />);
|
||||||
|
expect(getByText('Manage tags')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows spinner before the content data query is complete', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
const { getAllByRole } = render(<RootWrapper />);
|
||||||
|
const spinner = getAllByRole('status')[0];
|
||||||
|
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows spinner before the taxonomy tags query is complete', async () => {
|
||||||
|
useIsTaxonomyListDataLoaded.mockReturnValue(false);
|
||||||
|
await act(async () => {
|
||||||
|
const { getAllByRole } = render(<RootWrapper />);
|
||||||
|
const spinner = getAllByRole('status')[1];
|
||||||
|
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the content display name after the query is complete', async () => {
|
||||||
|
useContentData.mockReturnValue({
|
||||||
|
isSuccess: true,
|
||||||
|
data: {
|
||||||
|
displayName: 'Unit 1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
const { getByText } = render(<RootWrapper />);
|
||||||
|
expect(getByText('Unit 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
|
||||||
|
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||||
|
useContentTaxonomyTagsData.mockReturnValue({
|
||||||
|
isSuccess: true,
|
||||||
|
data: {
|
||||||
|
taxonomies: [
|
||||||
|
{
|
||||||
|
name: 'Taxonomy 1',
|
||||||
|
taxonomyId: 123,
|
||||||
|
canTagObject: true,
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
value: 'Tag 1',
|
||||||
|
lineage: ['Tag 1'],
|
||||||
|
canDeleteObjecttag: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'Tag 2',
|
||||||
|
lineage: ['Tag 2'],
|
||||||
|
canDeleteObjecttag: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Taxonomy 2',
|
||||||
|
taxonomyId: 124,
|
||||||
|
canTagObject: true,
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
value: 'Tag 3',
|
||||||
|
lineage: ['Tag 3'],
|
||||||
|
canDeleteObjecttag: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
useTaxonomyListDataResponse.mockReturnValue({
|
||||||
|
results: [{
|
||||||
|
id: 123,
|
||||||
|
name: 'Taxonomy 1',
|
||||||
|
description: 'This is a description 1',
|
||||||
|
canTagObject: false,
|
||||||
|
}, {
|
||||||
|
id: 124,
|
||||||
|
name: 'Taxonomy 2',
|
||||||
|
description: 'This is a description 2',
|
||||||
|
canTagObject: false,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
const { container, getByText } = render(<RootWrapper />);
|
||||||
|
expect(getByText('Taxonomy 1')).toBeInTheDocument();
|
||||||
|
expect(getByText('Taxonomy 2')).toBeInTheDocument();
|
||||||
|
const tagCountBadges = container.getElementsByClassName('badge');
|
||||||
|
expect(tagCountBadges[0].textContent).toBe('2');
|
||||||
|
expect(tagCountBadges[1].textContent).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call closeContentTagsDrawer when CloseButton is clicked', async () => {
|
||||||
|
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
|
||||||
|
|
||||||
|
const { getByTestId } = render(<RootWrapper />);
|
||||||
|
|
||||||
|
// Find the CloseButton element by its test ID and trigger a click event
|
||||||
|
const closeButton = getByTestId('drawer-close-button');
|
||||||
|
fireEvent.click(closeButton);
|
||||||
|
|
||||||
|
expect(postMessageSpy).toHaveBeenCalledWith('closeManageTagsDrawer', '*');
|
||||||
|
|
||||||
|
postMessageSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call closeContentTagsDrawer when Escape key is pressed and no selectable box is active', () => {
|
||||||
|
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
|
||||||
|
|
||||||
|
const { container } = render(<RootWrapper />);
|
||||||
|
|
||||||
|
fireEvent.keyDown(container, {
|
||||||
|
key: 'Escape',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(postMessageSpy).toHaveBeenCalledWith('closeManageTagsDrawer', '*');
|
||||||
|
|
||||||
|
postMessageSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call closeContentTagsDrawer when Escape key is pressed and a selectable box is active', () => {
|
||||||
|
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
|
||||||
|
|
||||||
|
const { container } = render(<RootWrapper />);
|
||||||
|
|
||||||
|
// Simulate that the selectable box is open by adding an element with the data attribute
|
||||||
|
const selectableBox = document.createElement('div');
|
||||||
|
selectableBox.setAttribute('data-selectable-box', 'taxonomy-tags');
|
||||||
|
document.body.appendChild(selectableBox);
|
||||||
|
|
||||||
|
fireEvent.keyDown(container, {
|
||||||
|
key: 'Escape',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(postMessageSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Remove the added element
|
||||||
|
document.body.removeChild(selectableBox);
|
||||||
|
|
||||||
|
postMessageSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
209
src/content-tags-drawer/ContentTagsDropDownSelector.jsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
// @ts-check
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
SelectableBox,
|
||||||
|
Icon,
|
||||||
|
Spinner,
|
||||||
|
Button,
|
||||||
|
} from '@edx/paragon';
|
||||||
|
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
import { ArrowDropDown, ArrowDropUp } from '@edx/paragon/icons';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import messages from './messages';
|
||||||
|
import './ContentTagsDropDownSelector.scss';
|
||||||
|
|
||||||
|
import { useTaxonomyTagsData } from './data/apiHooks';
|
||||||
|
|
||||||
|
const HighlightedText = ({ text, highlight }) => {
|
||||||
|
if (!highlight) {
|
||||||
|
return <span>{text}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = text.split(new RegExp(`(${highlight})`, 'gi'));
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{parts.map((part, index) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key -- using index because part is not unique
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{part.toLowerCase() === highlight.toLowerCase() ? <b>{part}</b> : part}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
HighlightedText.propTypes = {
|
||||||
|
text: PropTypes.string.isRequired,
|
||||||
|
highlight: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
HighlightedText.defaultProps = {
|
||||||
|
highlight: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContentTagsDropDownSelector = ({
|
||||||
|
taxonomyId, level, lineage, tagsTree, searchTerm,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
// This object represents the states of the dropdowns on this level
|
||||||
|
// The keys represent the index of the dropdown with
|
||||||
|
// the value true (open) false (closed)
|
||||||
|
const [dropdownStates, setDropdownStates] = useState(/** type Record<string, boolean> */ {});
|
||||||
|
const isOpen = (tagValue) => dropdownStates[tagValue];
|
||||||
|
|
||||||
|
const [numPages, setNumPages] = useState(1);
|
||||||
|
const parentTagValue = lineage.length ? decodeURIComponent(lineage[lineage.length - 1]) : null;
|
||||||
|
const { hasMorePages, tagPages } = useTaxonomyTagsData(taxonomyId, parentTagValue, numPages, searchTerm);
|
||||||
|
|
||||||
|
const [prevSearchTerm, setPrevSearchTerm] = useState(searchTerm);
|
||||||
|
|
||||||
|
// Reset the page and tags state when search term changes
|
||||||
|
// and store search term to compare
|
||||||
|
if (prevSearchTerm !== searchTerm) {
|
||||||
|
setPrevSearchTerm(searchTerm);
|
||||||
|
setNumPages(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tagPages.isSuccess) {
|
||||||
|
if (searchTerm) {
|
||||||
|
const expandAll = tagPages.data.reduce(
|
||||||
|
(acc, tagData) => ({
|
||||||
|
...acc,
|
||||||
|
[tagData.value]: !!tagData.childCount,
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
setDropdownStates(expandAll);
|
||||||
|
} else {
|
||||||
|
setDropdownStates({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [searchTerm, tagPages.isSuccess]);
|
||||||
|
|
||||||
|
const clickAndEnterHandler = (tagValue) => {
|
||||||
|
// This flips the state of the dropdown at index false (closed) -> true (open)
|
||||||
|
// and vice versa. Initially they are undefined which is falsy.
|
||||||
|
setDropdownStates({ ...dropdownStates, [tagValue]: !dropdownStates[tagValue] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isImplicit = (tag) => {
|
||||||
|
// Traverse the tags tree using the lineage
|
||||||
|
let traversal = tagsTree;
|
||||||
|
lineage.forEach(t => {
|
||||||
|
traversal = traversal[t]?.children || {};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (traversal[tag.value] && !traversal[tag.value].explicit) || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreTags = useCallback(() => {
|
||||||
|
setNumPages((x) => x + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginLeft: `${level * 1 }rem` }}>
|
||||||
|
{tagPages.isLoading ? (
|
||||||
|
<div className="d-flex justify-content-center align-items-center flex-row">
|
||||||
|
<Spinner
|
||||||
|
animation="border"
|
||||||
|
size="xl"
|
||||||
|
screenReaderText={intl.formatMessage(messages.loadingTagsDropdownMessage)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null }
|
||||||
|
{tagPages.isError ? 'Error...' : null /* TODO: show a proper error message */}
|
||||||
|
|
||||||
|
{tagPages.data?.map((tagData) => (
|
||||||
|
<React.Fragment key={tagData.value}>
|
||||||
|
<div
|
||||||
|
className="d-flex flex-row"
|
||||||
|
style={{
|
||||||
|
minHeight: '44px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="d-flex">
|
||||||
|
<SelectableBox
|
||||||
|
inputHidden={false}
|
||||||
|
type="checkbox"
|
||||||
|
className="d-flex align-items-center taxonomy-tags-selectable-box"
|
||||||
|
aria-label={intl.formatMessage(messages.taxonomyTagsCheckboxAriaLabel, { tag: tagData.value })}
|
||||||
|
data-selectable-box="taxonomy-tags"
|
||||||
|
value={[...lineage, tagData.value].map(t => encodeURIComponent(t)).join(',')}
|
||||||
|
isIndeterminate={isImplicit(tagData)}
|
||||||
|
disabled={isImplicit(tagData)}
|
||||||
|
>
|
||||||
|
<HighlightedText text={tagData.value} highlight={searchTerm} />
|
||||||
|
</SelectableBox>
|
||||||
|
{ tagData.childCount > 0
|
||||||
|
&& (
|
||||||
|
<div className="d-flex align-items-center taxonomy-tags-arrow-drop-down">
|
||||||
|
<Icon
|
||||||
|
src={isOpen(tagData.value) ? ArrowDropUp : ArrowDropDown}
|
||||||
|
onClick={() => clickAndEnterHandler(tagData.value)}
|
||||||
|
tabIndex="0"
|
||||||
|
onKeyPress={(event) => (event.key === 'Enter' ? clickAndEnterHandler(tagData.value) : null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ tagData.childCount > 0 && isOpen(tagData.value) && (
|
||||||
|
<ContentTagsDropDownSelector
|
||||||
|
taxonomyId={taxonomyId}
|
||||||
|
level={level + 1}
|
||||||
|
lineage={[...lineage, tagData.value]}
|
||||||
|
tagsTree={tagsTree}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{ hasMorePages
|
||||||
|
? (
|
||||||
|
<div className="d-flex justify-content-center align-items-center flex-row">
|
||||||
|
<Button
|
||||||
|
variant="outline-primary"
|
||||||
|
onClick={loadMoreTags}
|
||||||
|
className="mb-2 taxonomy-tags-load-more-button"
|
||||||
|
>
|
||||||
|
<FormattedMessage {...messages.loadMoreTagsButtonText} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
{ tagPages.data.length === 0 && !tagPages.isLoading && (
|
||||||
|
<div className="d-flex justify-content-center muted-text">
|
||||||
|
<FormattedMessage {...messages.noTagsFoundMessage} values={{ searchTerm }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ContentTagsDropDownSelector.defaultProps = {
|
||||||
|
lineage: [],
|
||||||
|
searchTerm: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
ContentTagsDropDownSelector.propTypes = {
|
||||||
|
taxonomyId: PropTypes.number.isRequired,
|
||||||
|
level: PropTypes.number.isRequired,
|
||||||
|
lineage: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
tagsTree: PropTypes.objectOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
explicit: PropTypes.bool.isRequired,
|
||||||
|
children: PropTypes.shape({}).isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
).isRequired,
|
||||||
|
searchTerm: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContentTagsDropDownSelector;
|
||||||
21
src/content-tags-drawer/ContentTagsDropDownSelector.scss
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
.taxonomy-tags-arrow-drop-down {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-tags-load-more-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pgn__selectable_box.taxonomy-tags-selectable-box {
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pgn__selectable_box.taxonomy-tags-selectable-box:disabled,
|
||||||
|
.pgn__selectable_box.taxonomy-tags-selectable-box[disabled] {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pgn__selectable_box-active.taxonomy-tags-selectable-box {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
368
src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
import {
|
||||||
|
act,
|
||||||
|
render,
|
||||||
|
waitFor,
|
||||||
|
fireEvent,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
|
||||||
|
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
|
||||||
|
import { useTaxonomyTagsData } from './data/apiHooks';
|
||||||
|
|
||||||
|
jest.mock('./data/apiHooks', () => ({
|
||||||
|
useTaxonomyTagsData: jest.fn(() => ({
|
||||||
|
hasMorePages: false,
|
||||||
|
tagPages: {
|
||||||
|
isLoading: true,
|
||||||
|
isError: false,
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
taxonomyId: 123,
|
||||||
|
level: 0,
|
||||||
|
tagsTree: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContentTagsDropDownSelectorComponent = ({
|
||||||
|
taxonomyId, level, lineage, tagsTree, searchTerm,
|
||||||
|
}) => (
|
||||||
|
<IntlProvider locale="en" messages={{}}>
|
||||||
|
<ContentTagsDropDownSelector
|
||||||
|
taxonomyId={taxonomyId}
|
||||||
|
level={level}
|
||||||
|
lineage={lineage}
|
||||||
|
tagsTree={tagsTree}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
/>
|
||||||
|
</IntlProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
ContentTagsDropDownSelectorComponent.defaultProps = {
|
||||||
|
lineage: [],
|
||||||
|
searchTerm: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
ContentTagsDropDownSelectorComponent.propTypes = ContentTagsDropDownSelector.propTypes;
|
||||||
|
|
||||||
|
describe('<ContentTagsDropDownSelector />', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render taxonomy tags drop down selector loading with spinner', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
const { getByRole } = render(
|
||||||
|
<ContentTagsDropDownSelectorComponent
|
||||||
|
taxonomyId={data.taxonomyId}
|
||||||
|
level={data.level}
|
||||||
|
tagsTree={data.tagsTree}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const spinner = getByRole('status');
|
||||||
|
expect(spinner.textContent).toEqual('Loading tags'); // Uses <Spinner />
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render taxonomy tags drop down selector with no sub tags', async () => {
|
||||||
|
useTaxonomyTagsData.mockReturnValue({
|
||||||
|
hasMorePages: false,
|
||||||
|
tagPages: {
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
data: [{
|
||||||
|
value: 'Tag 1',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 0,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 12345,
|
||||||
|
subTagsUrl: null,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const { container, getByText } = render(
|
||||||
|
<ContentTagsDropDownSelectorComponent
|
||||||
|
key={`selector-${data.taxonomyId}`}
|
||||||
|
taxonomyId={data.taxonomyId}
|
||||||
|
level={data.level}
|
||||||
|
tagsTree={data.tagsTree}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Tag 1')).toBeInTheDocument();
|
||||||
|
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render taxonomy tags drop down selector with sub tags', async () => {
|
||||||
|
useTaxonomyTagsData.mockReturnValue({
|
||||||
|
hasMorePages: false,
|
||||||
|
tagPages: {
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
data: [{
|
||||||
|
value: 'Tag 2',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 1,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 12345,
|
||||||
|
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const { container, getByText } = render(
|
||||||
|
<ContentTagsDropDownSelectorComponent
|
||||||
|
taxonomyId={data.taxonomyId}
|
||||||
|
level={data.level}
|
||||||
|
tagsTree={data.tagsTree}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||||
|
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expand on click taxonomy tags drop down selector with sub tags', async () => {
|
||||||
|
useTaxonomyTagsData.mockReturnValueOnce({
|
||||||
|
hasMorePages: false,
|
||||||
|
tagPages: {
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
data: [{
|
||||||
|
value: 'Tag 2',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 1,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 12345,
|
||||||
|
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const dataWithTagsTree = {
|
||||||
|
...data,
|
||||||
|
tagsTree: {
|
||||||
|
'Tag 3': {
|
||||||
|
explicit: false,
|
||||||
|
children: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { container, getByText } = render(
|
||||||
|
<ContentTagsDropDownSelectorComponent
|
||||||
|
taxonomyId={dataWithTagsTree.taxonomyId}
|
||||||
|
level={dataWithTagsTree.level}
|
||||||
|
tagsTree={dataWithTagsTree.tagsTree}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||||
|
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock useTaxonomyTagsData again since it gets called in the recursive call
|
||||||
|
useTaxonomyTagsData.mockReturnValueOnce({
|
||||||
|
hasMorePages: false,
|
||||||
|
tagPages: {
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
data: [{
|
||||||
|
value: 'Tag 3',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 0,
|
||||||
|
depth: 1,
|
||||||
|
parentValue: 'Tag 2',
|
||||||
|
id: 12346,
|
||||||
|
subTagsUrl: null,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expand the dropdown to see the subtags selectors
|
||||||
|
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
|
||||||
|
fireEvent.click(expandToggle);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expand on enter key taxonomy tags drop down selector with sub tags', async () => {
|
||||||
|
useTaxonomyTagsData.mockReturnValueOnce({
|
||||||
|
hasMorePages: false,
|
||||||
|
tagPages: {
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
data: [{
|
||||||
|
value: 'Tag 2',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 1,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 12345,
|
||||||
|
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const dataWithTagsTree = {
|
||||||
|
...data,
|
||||||
|
tagsTree: {
|
||||||
|
'Tag 3': {
|
||||||
|
explicit: false,
|
||||||
|
children: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { container, getByText } = render(
|
||||||
|
<ContentTagsDropDownSelectorComponent
|
||||||
|
taxonomyId={dataWithTagsTree.taxonomyId}
|
||||||
|
level={dataWithTagsTree.level}
|
||||||
|
tagsTree={dataWithTagsTree.tagsTree}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||||
|
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock useTaxonomyTagsData again since it gets called in the recursive call
|
||||||
|
useTaxonomyTagsData.mockReturnValueOnce({
|
||||||
|
hasMorePages: false,
|
||||||
|
tagPages: {
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
data: [{
|
||||||
|
value: 'Tag 3',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 0,
|
||||||
|
depth: 1,
|
||||||
|
parentValue: 'Tag 2',
|
||||||
|
id: 12346,
|
||||||
|
subTagsUrl: null,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expand the dropdown to see the subtags selectors
|
||||||
|
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
|
||||||
|
fireEvent.keyPress(expandToggle, { key: 'Enter', charCode: 13 });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render taxonomy tags drop down selector and change search term', async () => {
|
||||||
|
useTaxonomyTagsData.mockReturnValueOnce({
|
||||||
|
hasMorePages: false,
|
||||||
|
tagPages: {
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: true,
|
||||||
|
data: [{
|
||||||
|
value: 'Tag 1',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 0,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 12345,
|
||||||
|
subTagsUrl: null,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const initalSearchTerm = 'test 1';
|
||||||
|
await act(async () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<ContentTagsDropDownSelectorComponent
|
||||||
|
key={`selector-${data.taxonomyId}`}
|
||||||
|
taxonomyId={data.taxonomyId}
|
||||||
|
level={data.level}
|
||||||
|
tagsTree={data.tagsTree}
|
||||||
|
searchTerm={initalSearchTerm}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, initalSearchTerm);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedSearchTerm = 'test 2';
|
||||||
|
rerender(<ContentTagsDropDownSelectorComponent
|
||||||
|
key={`selector-${data.taxonomyId}`}
|
||||||
|
taxonomyId={data.taxonomyId}
|
||||||
|
level={data.level}
|
||||||
|
tagsTree={data.tagsTree}
|
||||||
|
searchTerm={updatedSearchTerm}
|
||||||
|
/>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, updatedSearchTerm);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean search term
|
||||||
|
const cleanSearchTerm = '';
|
||||||
|
rerender(<ContentTagsDropDownSelectorComponent
|
||||||
|
key={`selector-${data.taxonomyId}`}
|
||||||
|
taxonomyId={data.taxonomyId}
|
||||||
|
level={data.level}
|
||||||
|
tagsTree={data.tagsTree}
|
||||||
|
searchTerm={cleanSearchTerm}
|
||||||
|
/>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, cleanSearchTerm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render "noTag" message if search doesnt return taxonomies', async () => {
|
||||||
|
useTaxonomyTagsData.mockReturnValueOnce({
|
||||||
|
hasMorePages: false,
|
||||||
|
tagPages: {
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: true,
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchTerm = 'uncommon search term';
|
||||||
|
await act(async () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<ContentTagsDropDownSelectorComponent
|
||||||
|
key={`selector-${data.taxonomyId}`}
|
||||||
|
taxonomyId={data.taxonomyId}
|
||||||
|
level={data.level}
|
||||||
|
tagsTree={data.tagsTree}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = `No tags found with the search term "${searchTerm}"`;
|
||||||
|
expect(getByText(message)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
81
src/content-tags-drawer/ContentTagsTree.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// @ts-check
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import TagBubble from './TagBubble';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that renders Tags under a Taxonomy in the nested tree format.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* "Science and Research": {
|
||||||
|
* explicit: false,
|
||||||
|
* children: {
|
||||||
|
* "Genetics Subcategory": {
|
||||||
|
* explicit: false,
|
||||||
|
* children: {
|
||||||
|
* "DNA Sequencing": {
|
||||||
|
* explicit: true,
|
||||||
|
* children: {}
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* },
|
||||||
|
* "Molecular, Cellular, and Microbiology": {
|
||||||
|
* explicit: false,
|
||||||
|
* children: {
|
||||||
|
* "Virology": {
|
||||||
|
* explicit: true,
|
||||||
|
* children: {}
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* @param {Object} props - The component props.
|
||||||
|
* @param {Object} props.tagsTree - Array of taxonomy tags that are applied to the content.
|
||||||
|
* @param {(
|
||||||
|
* tagSelectableBoxValue: string,
|
||||||
|
* checked: boolean
|
||||||
|
* ) => void} props.removeTagHandler - Function that is called when removing tags from the tree.
|
||||||
|
*/
|
||||||
|
const ContentTagsTree = ({ tagsTree, removeTagHandler }) => {
|
||||||
|
const renderTagsTree = (tag, level, lineage) => Object.keys(tag).map((key) => {
|
||||||
|
const updatedLineage = [...lineage, encodeURIComponent(key)];
|
||||||
|
if (tag[key] !== undefined) {
|
||||||
|
return (
|
||||||
|
<div key={`tag-${key}-level-${level}`}>
|
||||||
|
<TagBubble
|
||||||
|
key={`tag-${key}`}
|
||||||
|
value={key}
|
||||||
|
implicit={!tag[key].explicit}
|
||||||
|
level={level}
|
||||||
|
lineage={updatedLineage}
|
||||||
|
removeTagHandler={removeTagHandler}
|
||||||
|
canRemove={tag[key].canDeleteObjecttag}
|
||||||
|
/>
|
||||||
|
{ renderTagsTree(tag[key].children, level + 1, updatedLineage) }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return <>{renderTagsTree(tagsTree, 0, [])}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
ContentTagsTree.propTypes = {
|
||||||
|
tagsTree: PropTypes.objectOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
explicit: PropTypes.bool.isRequired,
|
||||||
|
children: PropTypes.shape({}).isRequired,
|
||||||
|
canDeleteObjecttag: PropTypes.bool.isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
).isRequired,
|
||||||
|
removeTagHandler: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContentTagsTree;
|
||||||
57
src/content-tags-drawer/ContentTagsTree.test.jsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
import { act, render } from '@testing-library/react';
|
||||||
|
|
||||||
|
import ContentTagsTree from './ContentTagsTree';
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
'Science and Research': {
|
||||||
|
explicit: false,
|
||||||
|
canDeleteObjecttag: false,
|
||||||
|
children: {
|
||||||
|
'Genetics Subcategory': {
|
||||||
|
explicit: false,
|
||||||
|
children: {
|
||||||
|
'DNA Sequencing': {
|
||||||
|
explicit: true,
|
||||||
|
children: {},
|
||||||
|
canDeleteObjecttag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
canDeleteObjecttag: false,
|
||||||
|
},
|
||||||
|
'Molecular, Cellular, and Microbiology': {
|
||||||
|
explicit: false,
|
||||||
|
children: {
|
||||||
|
Virology: {
|
||||||
|
explicit: true,
|
||||||
|
children: {},
|
||||||
|
canDeleteObjecttag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
canDeleteObjecttag: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContentTagsTreeComponent = ({ tagsTree, removeTagHandler }) => (
|
||||||
|
<IntlProvider locale="en" messages={{}}>
|
||||||
|
<ContentTagsTree tagsTree={tagsTree} removeTagHandler={removeTagHandler} />
|
||||||
|
</IntlProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
ContentTagsTreeComponent.propTypes = ContentTagsTree.propTypes;
|
||||||
|
|
||||||
|
describe('<ContentTagsTree />', () => {
|
||||||
|
it('should render taxonomy tags data along content tags number badge', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
const { getByText } = render(<ContentTagsTreeComponent tagsTree={data} removeTagHandler={() => {}} />);
|
||||||
|
expect(getByText('Science and Research')).toBeInTheDocument();
|
||||||
|
expect(getByText('Genetics Subcategory')).toBeInTheDocument();
|
||||||
|
expect(getByText('Molecular, Cellular, and Microbiology')).toBeInTheDocument();
|
||||||
|
expect(getByText('DNA Sequencing')).toBeInTheDocument();
|
||||||
|
expect(getByText('Virology')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
51
src/content-tags-drawer/TagBubble.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Chip,
|
||||||
|
} from '@edx/paragon';
|
||||||
|
import { Tag, Close } from '@edx/paragon/icons';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import TagOutlineIcon from './TagOutlineIcon';
|
||||||
|
|
||||||
|
const TagBubble = ({
|
||||||
|
value, implicit, level, lineage, removeTagHandler, canRemove,
|
||||||
|
}) => {
|
||||||
|
const className = `tag-bubble mb-2 border-light-300 ${implicit ? 'implicit' : ''}`;
|
||||||
|
|
||||||
|
const handleClick = React.useCallback(() => {
|
||||||
|
if (!implicit && canRemove) {
|
||||||
|
removeTagHandler(lineage.join(','), false);
|
||||||
|
}
|
||||||
|
}, [implicit, lineage, canRemove, removeTagHandler]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingLeft: `${level * 1}rem` }}>
|
||||||
|
<Chip
|
||||||
|
className={className}
|
||||||
|
variant="light"
|
||||||
|
iconBefore={!implicit ? Tag : TagOutlineIcon}
|
||||||
|
iconAfter={!implicit && canRemove ? Close : null}
|
||||||
|
onIconAfterClick={handleClick}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
TagBubble.defaultProps = {
|
||||||
|
implicit: true,
|
||||||
|
level: 0,
|
||||||
|
canRemove: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
TagBubble.propTypes = {
|
||||||
|
value: PropTypes.string.isRequired,
|
||||||
|
implicit: PropTypes.bool,
|
||||||
|
level: PropTypes.number,
|
||||||
|
lineage: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||||
|
removeTagHandler: PropTypes.func.isRequired,
|
||||||
|
canRemove: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TagBubble;
|
||||||
5
src/content-tags-drawer/TagBubble.scss
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.tag-bubble.pgn__chip {
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 2px;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
109
src/content-tags-drawer/TagBubble.test.jsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
|
import TagBubble from './TagBubble';
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
value: 'Tag 1',
|
||||||
|
lineage: [],
|
||||||
|
removeTagHandler: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const TagBubbleComponent = ({
|
||||||
|
value, implicit, level, lineage, removeTagHandler, canRemove,
|
||||||
|
}) => (
|
||||||
|
<IntlProvider locale="en" messages={{}}>
|
||||||
|
<TagBubble
|
||||||
|
value={value}
|
||||||
|
implicit={implicit}
|
||||||
|
level={level}
|
||||||
|
lineage={lineage}
|
||||||
|
removeTagHandler={removeTagHandler}
|
||||||
|
canRemove={canRemove}
|
||||||
|
/>
|
||||||
|
</IntlProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
TagBubbleComponent.defaultProps = {
|
||||||
|
implicit: true,
|
||||||
|
level: 0,
|
||||||
|
canRemove: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
TagBubbleComponent.propTypes = TagBubble.propTypes;
|
||||||
|
|
||||||
|
describe('<TagBubble />', () => {
|
||||||
|
it('should render implicit tag', () => {
|
||||||
|
const { container, getByText } = render(
|
||||||
|
<TagBubbleComponent
|
||||||
|
value={data.value}
|
||||||
|
lineage={data.lineage}
|
||||||
|
removeTagHandler={data.removeTagHandler}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(getByText(data.value)).toBeInTheDocument();
|
||||||
|
expect(container.getElementsByClassName('implicit').length).toBe(1);
|
||||||
|
expect(container.getElementsByClassName('pgn__chip__icon-after').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render explicit tag', () => {
|
||||||
|
const tagBubbleData = {
|
||||||
|
implicit: false,
|
||||||
|
canRemove: true,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
const { container, getByText } = render(
|
||||||
|
<TagBubbleComponent
|
||||||
|
value={tagBubbleData.value}
|
||||||
|
canRemove={tagBubbleData.canRemove}
|
||||||
|
lineage={data.lineage}
|
||||||
|
implicit={tagBubbleData.implicit}
|
||||||
|
removeTagHandler={tagBubbleData.removeTagHandler}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(getByText(`${tagBubbleData.value}`)).toBeInTheDocument();
|
||||||
|
expect(container.getElementsByClassName('implicit').length).toBe(0);
|
||||||
|
expect(container.getElementsByClassName('pgn__chip__icon-after').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call removeTagHandler when "x" clicked on explicit tag', async () => {
|
||||||
|
const tagBubbleData = {
|
||||||
|
implicit: false,
|
||||||
|
canRemove: true,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
const { container } = render(
|
||||||
|
<TagBubbleComponent
|
||||||
|
value={tagBubbleData.value}
|
||||||
|
canRemove={tagBubbleData.canRemove}
|
||||||
|
lineage={data.lineage}
|
||||||
|
implicit={tagBubbleData.implicit}
|
||||||
|
removeTagHandler={tagBubbleData.removeTagHandler}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const xButton = container.getElementsByClassName('pgn__chip__icon-after')[0];
|
||||||
|
fireEvent.click(xButton);
|
||||||
|
expect(data.removeTagHandler).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show "x" when canRemove is not allowed', async () => {
|
||||||
|
const tagBubbleData = {
|
||||||
|
implicit: false,
|
||||||
|
canRemove: false,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
const { container } = render(
|
||||||
|
<TagBubbleComponent
|
||||||
|
value={tagBubbleData.value}
|
||||||
|
canRemove={tagBubbleData.canRemove}
|
||||||
|
lineage={data.lineage}
|
||||||
|
implicit={tagBubbleData.implicit}
|
||||||
|
removeTagHandler={tagBubbleData.removeTagHandler}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.getElementsByClassName('pgn__chip__icon-after')[0]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
20
src/content-tags-drawer/TagOutlineIcon.jsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const TagOutlineIcon = (props) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height="24px"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24px"
|
||||||
|
fill="currentColor"
|
||||||
|
role="img"
|
||||||
|
focusable="false"
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m21.41 11.58-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM13 20.01 4 11V4h7v-.01l9 9-7 7.02z"
|
||||||
|
/>
|
||||||
|
<circle cx="6.5" cy="6.5" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default TagOutlineIcon;
|
||||||
63
src/content-tags-drawer/__mocks__/contentDataMock.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
|
||||||
|
displayName: 'Unit 1.1.2',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Nov 12, 2023 at 09:53 UTC',
|
||||||
|
published: false,
|
||||||
|
publishedOn: null,
|
||||||
|
studioUrl: '/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
|
||||||
|
releasedToStudents: false,
|
||||||
|
releaseDate: null,
|
||||||
|
visibilityState: 'needs_attention',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2030-01-01T00:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Lab',
|
||||||
|
'Midterm Exam',
|
||||||
|
'Final Exam',
|
||||||
|
],
|
||||||
|
hasChanges: true,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
taxonomyTagsWidgetUrl: 'http://localhost:2001/tagging/components/widget/',
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
enableCopyPasteUnits: true,
|
||||||
|
useTaggingTaxonomyListPage: true,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
50
src/content-tags-drawer/__mocks__/contentTaxonomyTagsMock.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
module.exports = {
|
||||||
|
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': {
|
||||||
|
taxonomies: [
|
||||||
|
{
|
||||||
|
name: 'FlatTaxonomy',
|
||||||
|
taxonomyId: 3,
|
||||||
|
canTagObject: true,
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
value: 'flat taxonomy tag 3856',
|
||||||
|
lineage: [
|
||||||
|
'flat taxonomy tag 3856',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'HierarchicalTaxonomy',
|
||||||
|
taxonomyId: 4,
|
||||||
|
canTagObject: true,
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
value: 'hierarchical taxonomy tag 1.7.59',
|
||||||
|
lineage: [
|
||||||
|
'hierarchical taxonomy tag 1',
|
||||||
|
'hierarchical taxonomy tag 1.7',
|
||||||
|
'hierarchical taxonomy tag 1.7.59',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'hierarchical taxonomy tag 2.13.46',
|
||||||
|
lineage: [
|
||||||
|
'hierarchical taxonomy tag 2',
|
||||||
|
'hierarchical taxonomy tag 2.13',
|
||||||
|
'hierarchical taxonomy tag 2.13.46',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'hierarchical taxonomy tag 3.4.50',
|
||||||
|
lineage: [
|
||||||
|
'hierarchical taxonomy tag 3',
|
||||||
|
'hierarchical taxonomy tag 3.4',
|
||||||
|
'hierarchical taxonomy tag 3.4.50',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
4
src/content-tags-drawer/__mocks__/index.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as taxonomyTagsMock } from './taxonomyTagsMock';
|
||||||
|
export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock';
|
||||||
|
export { default as contentDataMock } from './contentDataMock';
|
||||||
|
export { default as updateContentTaxonomyTagsMock } from './updateContentTaxonomyTagsMock';
|
||||||
46
src/content-tags-drawer/__mocks__/taxonomyTagsMock.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
module.exports = {
|
||||||
|
next: null,
|
||||||
|
previous: null,
|
||||||
|
count: 4,
|
||||||
|
numPages: 1,
|
||||||
|
currentPage: 1,
|
||||||
|
start: 0,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
value: 'tag 1',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 16,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 635951,
|
||||||
|
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%201',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'tag 2',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 16,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 636992,
|
||||||
|
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%202',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'tag 3',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 16,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 638033,
|
||||||
|
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%203',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'tag 4',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 16,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 639074,
|
||||||
|
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%204',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
module.exports = {
|
||||||
|
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': {
|
||||||
|
taxonomies: [
|
||||||
|
{
|
||||||
|
name: 'FlatTaxonomy',
|
||||||
|
taxonomyId: 3,
|
||||||
|
canTagObject: true,
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
value: 'flat taxonomy tag 100',
|
||||||
|
lineage: [
|
||||||
|
'flat taxonomy tag 100',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'flat taxonomy tag 3856',
|
||||||
|
lineage: [
|
||||||
|
'flat taxonomy tag 3856',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
82
src/content-tags-drawer/data/api.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
|
||||||
|
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URL used to fetch tags data from the "taxonomy tags" REST API
|
||||||
|
* @param {number} taxonomyId
|
||||||
|
* @param {{page?: number, searchTerm?: string, parentTag?: string}} options
|
||||||
|
* @returns {string} the URL
|
||||||
|
*/
|
||||||
|
export const getTaxonomyTagsApiUrl = (taxonomyId, options = {}) => {
|
||||||
|
const url = new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl());
|
||||||
|
if (options.parentTag) {
|
||||||
|
url.searchParams.append('parent_tag', options.parentTag);
|
||||||
|
}
|
||||||
|
if (options.page) {
|
||||||
|
url.searchParams.append('page', String(options.page));
|
||||||
|
}
|
||||||
|
if (options.searchTerm) {
|
||||||
|
url.searchParams.append('search_term', options.searchTerm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load in the full tree if children at once, if we can:
|
||||||
|
// Note: do not combine this with page_size (we currently aren't using page_size)
|
||||||
|
url.searchParams.append('full_depth_threshold', '1000');
|
||||||
|
|
||||||
|
return url.href;
|
||||||
|
};
|
||||||
|
export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tags/${contentId}/`, getApiBaseUrl()).href;
|
||||||
|
export const getXBlockContentDataApiURL = (contentId) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href;
|
||||||
|
export const getLibraryContentDataApiUrl = (contentId) => new URL(`/api/libraries/v2/blocks/${contentId}/`, getApiBaseUrl()).href;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all tags that belong to taxonomy.
|
||||||
|
* @param {number} taxonomyId The id of the taxonomy to fetch tags for
|
||||||
|
* @param {{page?: number, searchTerm?: string, parentTag?: string}} options
|
||||||
|
* @returns {Promise<import("../../taxonomy/tag-list/data/types.mjs").TagListData>}
|
||||||
|
*/
|
||||||
|
export async function getTaxonomyTagsData(taxonomyId, options = {}) {
|
||||||
|
const url = getTaxonomyTagsApiUrl(taxonomyId, options);
|
||||||
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||||
|
return camelCaseObject(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tags that are applied to the content object
|
||||||
|
* @param {string} contentId The id of the content object to fetch the applied tags for
|
||||||
|
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
|
||||||
|
*/
|
||||||
|
export async function getContentTaxonomyTagsData(contentId) {
|
||||||
|
const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsApiUrl(contentId));
|
||||||
|
return camelCaseObject(data[contentId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch meta data (eg: display_name) about the content object (unit/compoenent)
|
||||||
|
* @param {string} contentId The id of the content object (unit/component)
|
||||||
|
* @returns {Promise<import("./types.mjs").ContentData>}
|
||||||
|
*/
|
||||||
|
export async function getContentData(contentId) {
|
||||||
|
const url = contentId.startsWith('lb:')
|
||||||
|
? getLibraryContentDataApiUrl(contentId)
|
||||||
|
: getXBlockContentDataApiURL(contentId);
|
||||||
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||||
|
return camelCaseObject(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update content object's applied tags
|
||||||
|
* @param {string} contentId The id of the content object (unit/component)
|
||||||
|
* @param {number} taxonomyId The id of the taxonomy the tags belong to
|
||||||
|
* @param {string[]} tags The list of tags (values) to set on content object
|
||||||
|
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
|
||||||
|
*/
|
||||||
|
export async function updateContentTaxonomyTags(contentId, taxonomyId, tags) {
|
||||||
|
const url = getContentTaxonomyTagsApiUrl(contentId);
|
||||||
|
const params = { taxonomy: taxonomyId };
|
||||||
|
const { data } = await getAuthenticatedHttpClient().put(url, { tags }, { params });
|
||||||
|
return camelCaseObject(data[contentId]);
|
||||||
|
}
|
||||||
119
src/content-tags-drawer/data/api.test.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// @ts-check
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import { initializeMockApp } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
|
||||||
|
import {
|
||||||
|
taxonomyTagsMock,
|
||||||
|
contentTaxonomyTagsMock,
|
||||||
|
contentDataMock,
|
||||||
|
updateContentTaxonomyTagsMock,
|
||||||
|
} from '../__mocks__';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTaxonomyTagsApiUrl,
|
||||||
|
getContentTaxonomyTagsApiUrl,
|
||||||
|
getXBlockContentDataApiURL,
|
||||||
|
getLibraryContentDataApiUrl,
|
||||||
|
getTaxonomyTagsData,
|
||||||
|
getContentTaxonomyTagsData,
|
||||||
|
getContentData,
|
||||||
|
updateContentTaxonomyTags,
|
||||||
|
} from './api';
|
||||||
|
|
||||||
|
let axiosMock;
|
||||||
|
|
||||||
|
describe('content tags drawer api calls', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
initializeMockApp({
|
||||||
|
authenticatedUser: {
|
||||||
|
userId: 3,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: true,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get taxonomy tags data', async () => {
|
||||||
|
const taxonomyId = 123;
|
||||||
|
axiosMock.onGet().reply(200, taxonomyTagsMock);
|
||||||
|
const result = await getTaxonomyTagsData(taxonomyId);
|
||||||
|
|
||||||
|
expect(axiosMock.history.get[0].url).toEqual(getTaxonomyTagsApiUrl(taxonomyId));
|
||||||
|
expect(result).toEqual(taxonomyTagsMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get taxonomy tags data with parentTag', async () => {
|
||||||
|
const taxonomyId = 123;
|
||||||
|
const options = { parentTag: 'Sample Tag' };
|
||||||
|
axiosMock.onGet().reply(200, taxonomyTagsMock);
|
||||||
|
const result = await getTaxonomyTagsData(taxonomyId, options);
|
||||||
|
|
||||||
|
expect(axiosMock.history.get[0].url).toContain('parent_tag=Sample+Tag');
|
||||||
|
expect(result).toEqual(taxonomyTagsMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get taxonomy tags data with page', async () => {
|
||||||
|
const taxonomyId = 123;
|
||||||
|
const options = { page: 2 };
|
||||||
|
axiosMock.onGet().reply(200, taxonomyTagsMock);
|
||||||
|
const result = await getTaxonomyTagsData(taxonomyId, options);
|
||||||
|
|
||||||
|
expect(axiosMock.history.get[0].url).toContain('page=2');
|
||||||
|
expect(result).toEqual(taxonomyTagsMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get taxonomy tags data with searchTerm', async () => {
|
||||||
|
const taxonomyId = 123;
|
||||||
|
const options = { searchTerm: 'memo' };
|
||||||
|
axiosMock.onGet().reply(200, taxonomyTagsMock);
|
||||||
|
const result = await getTaxonomyTagsData(taxonomyId, options);
|
||||||
|
|
||||||
|
expect(axiosMock.history.get[0].url).toContain('search_term=memo');
|
||||||
|
expect(result).toEqual(taxonomyTagsMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get content taxonomy tags data', async () => {
|
||||||
|
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
|
||||||
|
axiosMock.onGet(getContentTaxonomyTagsApiUrl(contentId)).reply(200, contentTaxonomyTagsMock);
|
||||||
|
const result = await getContentTaxonomyTagsData(contentId);
|
||||||
|
|
||||||
|
expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsApiUrl(contentId));
|
||||||
|
expect(result).toEqual(contentTaxonomyTagsMock[contentId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get content data for course component', async () => {
|
||||||
|
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
|
||||||
|
axiosMock.onGet(getXBlockContentDataApiURL(contentId)).reply(200, contentDataMock);
|
||||||
|
const result = await getContentData(contentId);
|
||||||
|
|
||||||
|
expect(axiosMock.history.get[0].url).toEqual(getXBlockContentDataApiURL(contentId));
|
||||||
|
expect(result).toEqual(contentDataMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get content data for V2 library component', async () => {
|
||||||
|
const contentId = 'lb:SampleTaxonomyOrg1:NTL1:html:a3eded6b-2106-429a-98be-63533d563d79';
|
||||||
|
axiosMock.onGet(getLibraryContentDataApiUrl(contentId)).reply(200, contentDataMock);
|
||||||
|
const result = await getContentData(contentId);
|
||||||
|
|
||||||
|
expect(axiosMock.history.get[0].url).toEqual(getLibraryContentDataApiUrl(contentId));
|
||||||
|
expect(result).toEqual(contentDataMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update content taxonomy tags', async () => {
|
||||||
|
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
|
||||||
|
const taxonomyId = 3;
|
||||||
|
const tags = ['flat taxonomy tag 100', 'flat taxonomy tag 3856'];
|
||||||
|
axiosMock.onPut(`${getContentTaxonomyTagsApiUrl(contentId)}`).reply(200, updateContentTaxonomyTagsMock);
|
||||||
|
const result = await updateContentTaxonomyTags(contentId, taxonomyId, tags);
|
||||||
|
|
||||||
|
expect(axiosMock.history.put[0].url).toEqual(`${getContentTaxonomyTagsApiUrl(contentId)}`);
|
||||||
|
expect(result).toEqual(updateContentTaxonomyTagsMock[contentId]);
|
||||||
|
});
|
||||||
|
});
|
||||||
142
src/content-tags-drawer/data/apiHooks.jsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
useQuery,
|
||||||
|
useQueries,
|
||||||
|
useMutation,
|
||||||
|
useQueryClient,
|
||||||
|
} from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getTaxonomyTagsData,
|
||||||
|
getContentTaxonomyTagsData,
|
||||||
|
getContentData,
|
||||||
|
updateContentTaxonomyTags,
|
||||||
|
} from './api';
|
||||||
|
|
||||||
|
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */
|
||||||
|
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagData} TagData */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the query to get the taxonomy tags
|
||||||
|
* @param {number} taxonomyId The id of the taxonomy to fetch tags for
|
||||||
|
* @param {string|null} parentTag The tag whose children we're loading, if any
|
||||||
|
* @param {string} searchTerm The term passed in to perform search on tags
|
||||||
|
* @param {number} numPages How many pages of tags to load at this level
|
||||||
|
*/
|
||||||
|
export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1, searchTerm = '') => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const queryFn = async ({ queryKey }) => {
|
||||||
|
const page = queryKey[3];
|
||||||
|
return getTaxonomyTagsData(taxonomyId, { parentTag: parentTag || '', searchTerm, page });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @type {{queryKey: any[], queryFn: typeof queryFn, staleTime: number}[]} */
|
||||||
|
const queries = [];
|
||||||
|
for (let page = 1; page <= numPages; page++) {
|
||||||
|
queries.push(
|
||||||
|
{ queryKey: ['taxonomyTags', taxonomyId, parentTag, page, searchTerm], queryFn, staleTime: Infinity },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataPages = useQueries({ queries });
|
||||||
|
|
||||||
|
const totalPages = dataPages[0]?.data?.numPages || 1;
|
||||||
|
const hasMorePages = numPages < totalPages;
|
||||||
|
|
||||||
|
const tagPages = useMemo(() => {
|
||||||
|
// Pre-load desendants if possible
|
||||||
|
const preLoadedData = new Map();
|
||||||
|
|
||||||
|
const newTags = dataPages.map(result => {
|
||||||
|
/** @type {TagData[]} */
|
||||||
|
const simplifiedTagsList = [];
|
||||||
|
|
||||||
|
result.data?.results?.forEach((tag) => {
|
||||||
|
if (tag.parentValue === parentTag) {
|
||||||
|
simplifiedTagsList.push(tag);
|
||||||
|
} else if (!preLoadedData.has(tag.parentValue)) {
|
||||||
|
preLoadedData.set(tag.parentValue, [tag]);
|
||||||
|
} else {
|
||||||
|
preLoadedData.get(tag.parentValue).push(tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...result, data: simplifiedTagsList };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the pre-loaded descendants into the query cache:
|
||||||
|
preLoadedData.forEach((tags, parentValue) => {
|
||||||
|
const queryKey = ['taxonomyTags', taxonomyId, parentValue, 1, searchTerm];
|
||||||
|
/** @type {TagListData} */
|
||||||
|
const cachedData = {
|
||||||
|
next: '',
|
||||||
|
previous: '',
|
||||||
|
count: tags.length,
|
||||||
|
numPages: 1,
|
||||||
|
currentPage: 1,
|
||||||
|
start: 0,
|
||||||
|
results: tags,
|
||||||
|
};
|
||||||
|
queryClient.setQueryData(queryKey, cachedData);
|
||||||
|
});
|
||||||
|
|
||||||
|
return newTags;
|
||||||
|
}, [dataPages]);
|
||||||
|
|
||||||
|
const flatTagPages = {
|
||||||
|
isLoading: tagPages.some(page => page.isLoading),
|
||||||
|
isError: tagPages.some(page => page.isError),
|
||||||
|
isSuccess: tagPages.every(page => page.isSuccess),
|
||||||
|
data: tagPages.flatMap(page => page.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { hasMorePages, tagPages: flatTagPages };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the query to get the taxonomy tags applied to the content object
|
||||||
|
* @param {string} contentId The ID of the content object to fetch the applied tags for (e.g. an XBlock usage key)
|
||||||
|
*/
|
||||||
|
export const useContentTaxonomyTagsData = (contentId) => (
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['contentTaxonomyTags', contentId],
|
||||||
|
queryFn: () => getContentTaxonomyTagsData(contentId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the query to get meta data about the content object
|
||||||
|
* @param {string} contentId The id of the content object (unit/component)
|
||||||
|
*/
|
||||||
|
export const useContentData = (contentId) => (
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['contentData', contentId],
|
||||||
|
queryFn: () => getContentData(contentId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the mutation to update the tags applied to the content object
|
||||||
|
* @param {string} contentId The id of the content object to update tags for
|
||||||
|
* @param {number} taxonomyId The id of the taxonomy the tags belong to
|
||||||
|
*/
|
||||||
|
export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
/**
|
||||||
|
* @type {import("@tanstack/react-query").MutateFunction<
|
||||||
|
* any,
|
||||||
|
* any,
|
||||||
|
* {
|
||||||
|
* tags: string[]
|
||||||
|
* }
|
||||||
|
* >}
|
||||||
|
*/
|
||||||
|
mutationFn: ({ tags }) => updateContentTaxonomyTags(contentId, taxonomyId, tags),
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
175
src/content-tags-drawer/data/apiHooks.test.jsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useQuery, useMutation, useQueries } from '@tanstack/react-query';
|
||||||
|
import { act } from '@testing-library/react';
|
||||||
|
import { renderHook } from '@testing-library/react-hooks';
|
||||||
|
import {
|
||||||
|
useTaxonomyTagsData,
|
||||||
|
useContentTaxonomyTagsData,
|
||||||
|
useContentData,
|
||||||
|
useContentTaxonomyTagsUpdater,
|
||||||
|
} from './apiHooks';
|
||||||
|
|
||||||
|
import { updateContentTaxonomyTags } from './api';
|
||||||
|
|
||||||
|
jest.mock('@tanstack/react-query', () => ({
|
||||||
|
useQuery: jest.fn(),
|
||||||
|
useMutation: jest.fn(),
|
||||||
|
useQueryClient: jest.fn(() => ({
|
||||||
|
setQueryData: jest.fn(),
|
||||||
|
})),
|
||||||
|
useQueries: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./api', () => ({
|
||||||
|
updateContentTaxonomyTags: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useTaxonomyTagsData', () => {
|
||||||
|
it('should call useQueries with the correct arguments', () => {
|
||||||
|
const taxonomyId = 123;
|
||||||
|
const mockData = {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
value: 'tag 1',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 16,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 635951,
|
||||||
|
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%201',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'tag 2',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 1,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 636992,
|
||||||
|
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%202',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'tag 3',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 0,
|
||||||
|
depth: 1,
|
||||||
|
parentValue: 'tag 2',
|
||||||
|
id: 636993,
|
||||||
|
subTagsUrl: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'tag 4',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 0,
|
||||||
|
depth: 1,
|
||||||
|
parentValue: 'tag 2',
|
||||||
|
id: 636994,
|
||||||
|
subTagsUrl: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
useQueries.mockReturnValue([{
|
||||||
|
data: mockData,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: true,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTaxonomyTagsData(taxonomyId));
|
||||||
|
|
||||||
|
// Assert that useQueries was called with the correct arguments
|
||||||
|
expect(useQueries).toHaveBeenCalledWith({
|
||||||
|
queries: [
|
||||||
|
{ queryKey: ['taxonomyTags', taxonomyId, null, 1, ''], queryFn: expect.any(Function), staleTime: Infinity },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.hasMorePages).toEqual(false);
|
||||||
|
// Only includes the first 2 tags because the other 2 would be
|
||||||
|
// in the nested dropdown
|
||||||
|
expect(result.current.tagPages).toEqual(
|
||||||
|
{
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
value: 'tag 1',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 16,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 635951,
|
||||||
|
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%201',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'tag 2',
|
||||||
|
externalId: null,
|
||||||
|
childCount: 1,
|
||||||
|
depth: 0,
|
||||||
|
parentValue: null,
|
||||||
|
id: 636992,
|
||||||
|
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%202',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useContentTaxonomyTagsData', () => {
|
||||||
|
it('should return success response', () => {
|
||||||
|
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
|
||||||
|
const contentId = '123';
|
||||||
|
const result = useContentTaxonomyTagsData(contentId);
|
||||||
|
|
||||||
|
expect(result).toEqual({ isSuccess: true, data: 'data' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return failure response', () => {
|
||||||
|
useQuery.mockReturnValueOnce({ isSuccess: false });
|
||||||
|
const contentId = '123';
|
||||||
|
const result = useContentTaxonomyTagsData(contentId);
|
||||||
|
|
||||||
|
expect(result).toEqual({ isSuccess: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useContentData', () => {
|
||||||
|
it('should return success response', () => {
|
||||||
|
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
|
||||||
|
const contentId = '123';
|
||||||
|
const result = useContentData(contentId);
|
||||||
|
|
||||||
|
expect(result).toEqual({ isSuccess: true, data: 'data' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return failure response', () => {
|
||||||
|
useQuery.mockReturnValueOnce({ isSuccess: false });
|
||||||
|
const contentId = '123';
|
||||||
|
const result = useContentData(contentId);
|
||||||
|
|
||||||
|
expect(result).toEqual({ isSuccess: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useContentTaxonomyTagsUpdater', () => {
|
||||||
|
it('should call the update content taxonomy tags function', async () => {
|
||||||
|
useMutation.mockReturnValueOnce({ mutate: jest.fn() });
|
||||||
|
|
||||||
|
const contentId = 'testerContent';
|
||||||
|
const taxonomyId = 123;
|
||||||
|
const mutation = useContentTaxonomyTagsUpdater(contentId, taxonomyId);
|
||||||
|
mutation.mutate({ tags: ['tag1', 'tag2'] });
|
||||||
|
|
||||||
|
expect(useMutation).toBeCalled();
|
||||||
|
|
||||||
|
const [config] = useMutation.mock.calls[0];
|
||||||
|
const { mutationFn } = config;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const tags = ['tag1', 'tag2'];
|
||||||
|
await mutationFn({ tags });
|
||||||
|
expect(updateContentTaxonomyTags).toBeCalledWith(contentId, taxonomyId, tags);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
60
src/content-tags-drawer/data/types.mjs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Tag A tag that has been applied to some content.
|
||||||
|
* @property {string} value The value of the tag, also its ID. e.g. "Biology"
|
||||||
|
* @property {string[]} lineage The values of the tag and its parent(s) in the hierarchy
|
||||||
|
* @property {boolean} canChangeObjecttag
|
||||||
|
* @property {boolean} canDeleteObjecttag
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ContentTaxonomyTagData A list of the tags from one taxonomy that are applied to a content object.
|
||||||
|
* @property {string} name
|
||||||
|
* @property {number} taxonomyId
|
||||||
|
* @property {boolean} canTagObject
|
||||||
|
* @property {Tag[]} tags
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ContentTaxonomyTagsData A list of all the tags applied to some content object, grouped by taxonomy.
|
||||||
|
* @property {ContentTaxonomyTagData[]} taxonomies
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ContentActions
|
||||||
|
* @property {boolean} deleteable
|
||||||
|
* @property {boolean} draggable
|
||||||
|
* @property {boolean} childAddable
|
||||||
|
* @property {boolean} duplicable
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ContentData
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} displayName
|
||||||
|
* @property {string} category
|
||||||
|
* @property {boolean} hasChildren
|
||||||
|
* @property {string} editedOn
|
||||||
|
* @property {boolean} published
|
||||||
|
* @property {string} publishedOn
|
||||||
|
* @property {string} studioUrl
|
||||||
|
* @property {boolean} releasedToStudents
|
||||||
|
* @property {string|null} releaseDate
|
||||||
|
* @property {string} visibilityState
|
||||||
|
* @property {boolean} hasExplicitStaffLock
|
||||||
|
* @property {string} start
|
||||||
|
* @property {boolean} graded
|
||||||
|
* @property {string} dueDate
|
||||||
|
* @property {string} due
|
||||||
|
* @property {string|null} relativeWeeksDue
|
||||||
|
* @property {string|null} format
|
||||||
|
* @property {boolean} hasChanges
|
||||||
|
* @property {ContentActions} actions
|
||||||
|
* @property {string} explanatoryMessage
|
||||||
|
* @property {string} showCorrectness
|
||||||
|
* @property {boolean} discussionEnabled
|
||||||
|
* @property {boolean} ancestorHasStaffLock
|
||||||
|
* @property {boolean} staffOnlyMessage
|
||||||
|
* @property {boolean} hasPartitionGroupComponents
|
||||||
|
*/
|
||||||
2
src/content-tags-drawer/index.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
|
export { default as ContentTagsDrawer } from './ContentTagsDrawer';
|
||||||
38
src/content-tags-drawer/messages.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
headerSubtitle: {
|
||||||
|
id: 'course-authoring.content-tags-drawer.header.subtitle',
|
||||||
|
defaultMessage: 'Manage tags',
|
||||||
|
},
|
||||||
|
addTagsButtonText: {
|
||||||
|
id: 'course-authoring.content-tags-drawer.collapsible.add-tags.button',
|
||||||
|
defaultMessage: 'Add tags',
|
||||||
|
},
|
||||||
|
loadingMessage: {
|
||||||
|
id: 'course-authoring.content-tags-drawer.spinner.loading',
|
||||||
|
defaultMessage: 'Loading',
|
||||||
|
},
|
||||||
|
loadingTagsDropdownMessage: {
|
||||||
|
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.spinner.loading',
|
||||||
|
defaultMessage: 'Loading tags',
|
||||||
|
},
|
||||||
|
loadMoreTagsButtonText: {
|
||||||
|
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.load-more-tags.button',
|
||||||
|
defaultMessage: 'Load more',
|
||||||
|
},
|
||||||
|
noTagsFoundMessage: {
|
||||||
|
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-found',
|
||||||
|
defaultMessage: 'No tags found with the search term "{searchTerm}"',
|
||||||
|
},
|
||||||
|
taxonomyTagsCheckboxAriaLabel: {
|
||||||
|
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.selectable-box.aria.label',
|
||||||
|
defaultMessage: '{tag} checkbox',
|
||||||
|
},
|
||||||
|
taxonomyTagsAriaLabel: {
|
||||||
|
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label',
|
||||||
|
defaultMessage: 'taxonomy tags selection',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default messages;
|
||||||
2
src/content-tags-drawer/utils.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
|
export const extractOrgFromContentId = (contentId) => contentId.split('+')[0].split(':')[1];
|
||||||
511
src/course-outline/CourseOutline.jsx
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Layout,
|
||||||
|
Row,
|
||||||
|
TransitionReplace,
|
||||||
|
} from '@edx/paragon';
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
import {
|
||||||
|
Add as IconAdd,
|
||||||
|
CheckCircle as CheckCircleIcon,
|
||||||
|
Warning as WarningIcon,
|
||||||
|
} from '@edx/paragon/icons';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { DraggableList } from '@edx/frontend-lib-content-components';
|
||||||
|
import { arrayMove } from '@dnd-kit/sortable';
|
||||||
|
|
||||||
|
import { LoadingSpinner } from '../generic/Loading';
|
||||||
|
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
|
||||||
|
import { RequestStatus } from '../data/constants';
|
||||||
|
import SubHeader from '../generic/sub-header/SubHeader';
|
||||||
|
import ProcessingNotification from '../generic/processing-notification';
|
||||||
|
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||||
|
import AlertMessage from '../generic/alert-message';
|
||||||
|
import getPageHeadTitle from '../generic/utils';
|
||||||
|
import HeaderNavigations from './header-navigations/HeaderNavigations';
|
||||||
|
import OutlineSideBar from './outline-sidebar/OutlineSidebar';
|
||||||
|
import StatusBar from './status-bar/StatusBar';
|
||||||
|
import EnableHighlightsModal from './enable-highlights-modal/EnableHighlightsModal';
|
||||||
|
import SectionCard from './section-card/SectionCard';
|
||||||
|
import SubsectionCard from './subsection-card/SubsectionCard';
|
||||||
|
import UnitCard from './unit-card/UnitCard';
|
||||||
|
import HighlightsModal from './highlights-modal/HighlightsModal';
|
||||||
|
import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder';
|
||||||
|
import PublishModal from './publish-modal/PublishModal';
|
||||||
|
import ConfigureModal from './configure-modal/ConfigureModal';
|
||||||
|
import DeleteModal from './delete-modal/DeleteModal';
|
||||||
|
import PageAlerts from './page-alerts/PageAlerts';
|
||||||
|
import { useCourseOutline } from './hooks';
|
||||||
|
import messages from './messages';
|
||||||
|
import { useUserPermissions } from '../generic/hooks';
|
||||||
|
import { getUserPermissionsEnabled } from '../generic/data/selectors';
|
||||||
|
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
|
||||||
|
|
||||||
|
const CourseOutline = ({ courseId }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const {
|
||||||
|
courseName,
|
||||||
|
savingStatus,
|
||||||
|
statusBarData,
|
||||||
|
courseActions,
|
||||||
|
sectionsList,
|
||||||
|
isCustomRelativeDatesActive,
|
||||||
|
isLoading,
|
||||||
|
isReIndexShow,
|
||||||
|
showErrorAlert,
|
||||||
|
showSuccessAlert,
|
||||||
|
isSectionsExpanded,
|
||||||
|
isEnableHighlightsModalOpen,
|
||||||
|
isInternetConnectionAlertFailed,
|
||||||
|
isDisabledReindexButton,
|
||||||
|
isHighlightsModalOpen,
|
||||||
|
isPublishModalOpen,
|
||||||
|
isConfigureModalOpen,
|
||||||
|
isDeleteModalOpen,
|
||||||
|
closeHighlightsModal,
|
||||||
|
closePublishModal,
|
||||||
|
handleConfigureModalClose,
|
||||||
|
closeDeleteModal,
|
||||||
|
openPublishModal,
|
||||||
|
openConfigureModal,
|
||||||
|
openDeleteModal,
|
||||||
|
headerNavigationsActions,
|
||||||
|
openEnableHighlightsModal,
|
||||||
|
closeEnableHighlightsModal,
|
||||||
|
handleEnableHighlightsSubmit,
|
||||||
|
handleInternetConnectionFailed,
|
||||||
|
handleOpenHighlightsModal,
|
||||||
|
handleHighlightsFormSubmit,
|
||||||
|
handleConfigureItemSubmit,
|
||||||
|
handlePublishItemSubmit,
|
||||||
|
handleEditSubmit,
|
||||||
|
handleDeleteItemSubmit,
|
||||||
|
handleDuplicateSectionSubmit,
|
||||||
|
handleDuplicateSubsectionSubmit,
|
||||||
|
handleDuplicateUnitSubmit,
|
||||||
|
handleNewSectionSubmit,
|
||||||
|
handleNewSubsectionSubmit,
|
||||||
|
handleNewUnitSubmit,
|
||||||
|
getUnitUrl,
|
||||||
|
handleSectionDragAndDrop,
|
||||||
|
handleSubsectionDragAndDrop,
|
||||||
|
handleVideoSharingOptionChange,
|
||||||
|
handleUnitDragAndDrop,
|
||||||
|
handleCopyToClipboardClick,
|
||||||
|
handlePasteClipboardClick,
|
||||||
|
notificationDismissUrl,
|
||||||
|
discussionsSettings,
|
||||||
|
discussionsIncontextFeedbackUrl,
|
||||||
|
discussionsIncontextLearnmoreUrl,
|
||||||
|
deprecatedBlocksInfo,
|
||||||
|
proctoringErrors,
|
||||||
|
mfeProctoredExamSettingsUrl,
|
||||||
|
handleDismissNotification,
|
||||||
|
advanceSettingsUrl,
|
||||||
|
} = useCourseOutline({ courseId });
|
||||||
|
|
||||||
|
const [sections, setSections] = useState(sectionsList);
|
||||||
|
|
||||||
|
const { checkPermission } = useUserPermissions();
|
||||||
|
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||||
|
const hasOutlinePermissions = !userPermissionsEnabled || (
|
||||||
|
userPermissionsEnabled && (checkPermission('manage_libraries') || checkPermission('manage_content'))
|
||||||
|
);
|
||||||
|
|
||||||
|
let initialSections = [...sectionsList];
|
||||||
|
|
||||||
|
const {
|
||||||
|
isShow: isShowProcessingNotification,
|
||||||
|
title: processingNotificationTitle,
|
||||||
|
} = useSelector(getProcessingNotification);
|
||||||
|
|
||||||
|
const finalizeSectionOrder = () => (newSections) => {
|
||||||
|
initialSections = [...sectionsList];
|
||||||
|
handleSectionDragAndDrop(newSections.map(section => section.id), () => {
|
||||||
|
setSections(() => initialSections);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSubsection = (index) => (updatedSubsection) => {
|
||||||
|
const section = { ...sections[index] };
|
||||||
|
section.childInfo = { ...section.childInfo };
|
||||||
|
section.childInfo.children = updatedSubsection();
|
||||||
|
setSections([...sections.slice(0, index), section, ...sections.slice(index + 1)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalizeSubsectionOrder = (section) => () => (newSubsections) => {
|
||||||
|
initialSections = [...sectionsList];
|
||||||
|
handleSubsectionDragAndDrop(section.id, newSubsections.map(subsection => subsection.id), () => {
|
||||||
|
setSections(() => initialSections);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setUnit = (sectionIndex, subsectionIndex) => (updatedUnits) => {
|
||||||
|
const section = { ...sections[sectionIndex] };
|
||||||
|
section.childInfo = { ...section.childInfo };
|
||||||
|
|
||||||
|
const subsection = { ...section.childInfo.children[subsectionIndex] };
|
||||||
|
subsection.childInfo = { ...subsection.childInfo };
|
||||||
|
subsection.childInfo.children = updatedUnits();
|
||||||
|
|
||||||
|
const updatedSubsections = [...section.childInfo.children];
|
||||||
|
updatedSubsections[subsectionIndex] = subsection;
|
||||||
|
section.childInfo.children = updatedSubsections;
|
||||||
|
setSections([...sections.slice(0, sectionIndex), section, ...sections.slice(sectionIndex + 1)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalizeUnitOrder = (section, subsection) => () => (newUnits) => {
|
||||||
|
initialSections = [...sectionsList];
|
||||||
|
handleUnitDragAndDrop(section.id, subsection.id, newUnits.map(unit => unit.id), () => {
|
||||||
|
setSections(() => initialSections);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if item can be moved by given step.
|
||||||
|
* Inner function returns false if the new index after moving by given step
|
||||||
|
* is out of bounds of item length.
|
||||||
|
* If it is within bounds, returns draggable flag of the item in the new index.
|
||||||
|
* This helps us avoid moving the item to a position of unmovable item.
|
||||||
|
* @param {Array} items
|
||||||
|
* @returns {(id, step) => bool}
|
||||||
|
*/
|
||||||
|
const canMoveItem = (items) => (id, step) => {
|
||||||
|
const newId = id + step;
|
||||||
|
const indexCheck = newId >= 0 && newId < items.length;
|
||||||
|
if (!indexCheck) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const newItem = items[newId];
|
||||||
|
return newItem.actions.draggable;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move section to new index
|
||||||
|
* @param {any} currentIndex
|
||||||
|
* @param {any} newIndex
|
||||||
|
*/
|
||||||
|
const updateSectionOrderByIndex = (currentIndex, newIndex) => {
|
||||||
|
if (currentIndex === newIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSections((prevSections) => {
|
||||||
|
const newSections = arrayMove(prevSections, currentIndex, newIndex);
|
||||||
|
finalizeSectionOrder()(newSections);
|
||||||
|
return newSections;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a function for given section which can move a subsection inside it
|
||||||
|
* to a new position
|
||||||
|
* @param {any} sectionIndex
|
||||||
|
* @param {any} section
|
||||||
|
* @param {any} subsections
|
||||||
|
* @returns {(currentIndex, newIndex) => void}
|
||||||
|
*/
|
||||||
|
const updateSubsectionOrderByIndex = (sectionIndex, section, subsections) => (currentIndex, newIndex) => {
|
||||||
|
if (currentIndex === newIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubsection(sectionIndex)(() => {
|
||||||
|
const newSubsections = arrayMove(subsections, currentIndex, newIndex);
|
||||||
|
finalizeSubsectionOrder(section)()(newSubsections);
|
||||||
|
return newSubsections;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a function for given section & subsection which can move a unit
|
||||||
|
* inside it to a new position
|
||||||
|
* @param {any} sectionIndex
|
||||||
|
* @param {any} section
|
||||||
|
* @param {any} subsection
|
||||||
|
* @param {any} units
|
||||||
|
* @returns {(currentIndex, newIndex) => void}
|
||||||
|
*/
|
||||||
|
const updateUnitOrderByIndex = (
|
||||||
|
sectionIndex,
|
||||||
|
subsectionIndex,
|
||||||
|
section,
|
||||||
|
subsection,
|
||||||
|
units,
|
||||||
|
) => (currentIndex, newIndex) => {
|
||||||
|
if (currentIndex === newIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUnit(sectionIndex, subsectionIndex)(() => {
|
||||||
|
const newUnits = arrayMove(units, currentIndex, newIndex);
|
||||||
|
finalizeUnitOrder(section, subsection)()(newUnits);
|
||||||
|
return newUnits;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSections(sectionsList);
|
||||||
|
}, [sectionsList]);
|
||||||
|
|
||||||
|
if (!hasOutlinePermissions) {
|
||||||
|
return (
|
||||||
|
<PermissionDeniedAlert />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||||
|
return (
|
||||||
|
<Row className="m-0 mt-4 justify-content-center">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>{getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))}</title>
|
||||||
|
</Helmet>
|
||||||
|
<Container size="xl" className="px-4">
|
||||||
|
<section className="course-outline-container mb-4 mt-5">
|
||||||
|
<PageAlerts
|
||||||
|
notificationDismissUrl={notificationDismissUrl}
|
||||||
|
handleDismissNotification={handleDismissNotification}
|
||||||
|
discussionsSettings={discussionsSettings}
|
||||||
|
discussionsIncontextFeedbackUrl={discussionsIncontextFeedbackUrl}
|
||||||
|
discussionsIncontextLearnmoreUrl={discussionsIncontextLearnmoreUrl}
|
||||||
|
deprecatedBlocksInfo={deprecatedBlocksInfo}
|
||||||
|
proctoringErrors={proctoringErrors}
|
||||||
|
mfeProctoredExamSettingsUrl={mfeProctoredExamSettingsUrl}
|
||||||
|
advanceSettingsUrl={advanceSettingsUrl}
|
||||||
|
savingStatus={savingStatus}
|
||||||
|
/>
|
||||||
|
<TransitionReplace>
|
||||||
|
{showSuccessAlert ? (
|
||||||
|
<AlertMessage
|
||||||
|
key={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
|
||||||
|
show={showSuccessAlert}
|
||||||
|
variant="success"
|
||||||
|
icon={CheckCircleIcon}
|
||||||
|
title={intl.formatMessage(messages.alertSuccessTitle)}
|
||||||
|
description={intl.formatMessage(messages.alertSuccessDescription)}
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-labelledby={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
|
||||||
|
aria-describedby={intl.formatMessage(messages.alertSuccessAriaDescribedby)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</TransitionReplace>
|
||||||
|
<SubHeader
|
||||||
|
className="mt-5"
|
||||||
|
title={intl.formatMessage(messages.headingTitle)}
|
||||||
|
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||||
|
headerActions={(
|
||||||
|
<HeaderNavigations
|
||||||
|
isReIndexShow={isReIndexShow}
|
||||||
|
isSectionsExpanded={isSectionsExpanded}
|
||||||
|
headerNavigationsActions={headerNavigationsActions}
|
||||||
|
isDisabledReindexButton={isDisabledReindexButton}
|
||||||
|
hasSections={Boolean(sectionsList.length)}
|
||||||
|
courseActions={courseActions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Layout
|
||||||
|
lg={[{ span: 9 }, { span: 3 }]}
|
||||||
|
md={[{ span: 9 }, { span: 3 }]}
|
||||||
|
sm={[{ span: 12 }, { span: 12 }]}
|
||||||
|
xs={[{ span: 12 }, { span: 12 }]}
|
||||||
|
xl={[{ span: 9 }, { span: 3 }]}
|
||||||
|
>
|
||||||
|
<Layout.Element>
|
||||||
|
<article>
|
||||||
|
<div>
|
||||||
|
<section className="course-outline-section">
|
||||||
|
<StatusBar
|
||||||
|
courseId={courseId}
|
||||||
|
isLoading={isLoading}
|
||||||
|
statusBarData={statusBarData}
|
||||||
|
openEnableHighlightsModal={openEnableHighlightsModal}
|
||||||
|
handleVideoSharingOptionChange={handleVideoSharingOptionChange}
|
||||||
|
/>
|
||||||
|
<div className="pt-4">
|
||||||
|
{sections.length ? (
|
||||||
|
<>
|
||||||
|
<DraggableList itemList={sections} setState={setSections} updateOrder={finalizeSectionOrder}>
|
||||||
|
{sections.map((section, sectionIndex) => (
|
||||||
|
<SectionCard
|
||||||
|
id={section.id}
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
index={sectionIndex}
|
||||||
|
canMoveItem={canMoveItem(sections)}
|
||||||
|
isSelfPaced={statusBarData.isSelfPaced}
|
||||||
|
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||||
|
savingStatus={savingStatus}
|
||||||
|
onOpenHighlightsModal={handleOpenHighlightsModal}
|
||||||
|
onOpenPublishModal={openPublishModal}
|
||||||
|
onOpenConfigureModal={openConfigureModal}
|
||||||
|
onOpenDeleteModal={openDeleteModal}
|
||||||
|
onEditSectionSubmit={handleEditSubmit}
|
||||||
|
onDuplicateSubmit={handleDuplicateSectionSubmit}
|
||||||
|
isSectionsExpanded={isSectionsExpanded}
|
||||||
|
onNewSubsectionSubmit={handleNewSubsectionSubmit}
|
||||||
|
onOrderChange={updateSectionOrderByIndex}
|
||||||
|
>
|
||||||
|
<DraggableList
|
||||||
|
itemList={section.childInfo.children}
|
||||||
|
setState={setSubsection(sectionIndex)}
|
||||||
|
updateOrder={finalizeSubsectionOrder(section)}
|
||||||
|
>
|
||||||
|
{section.childInfo.children.map((subsection, subsectionIndex) => (
|
||||||
|
<SubsectionCard
|
||||||
|
key={subsection.id}
|
||||||
|
section={section}
|
||||||
|
subsection={subsection}
|
||||||
|
index={subsectionIndex}
|
||||||
|
canMoveItem={canMoveItem(section.childInfo.children)}
|
||||||
|
isSelfPaced={statusBarData.isSelfPaced}
|
||||||
|
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||||
|
savingStatus={savingStatus}
|
||||||
|
onOpenPublishModal={openPublishModal}
|
||||||
|
onOpenDeleteModal={openDeleteModal}
|
||||||
|
onEditSubmit={handleEditSubmit}
|
||||||
|
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
|
||||||
|
onOpenConfigureModal={openConfigureModal}
|
||||||
|
onNewUnitSubmit={handleNewUnitSubmit}
|
||||||
|
onOrderChange={updateSubsectionOrderByIndex(
|
||||||
|
sectionIndex,
|
||||||
|
section,
|
||||||
|
section.childInfo.children,
|
||||||
|
)}
|
||||||
|
onPasteClick={handlePasteClipboardClick}
|
||||||
|
>
|
||||||
|
<DraggableList
|
||||||
|
itemList={subsection.childInfo.children}
|
||||||
|
setState={setUnit(sectionIndex, subsectionIndex)}
|
||||||
|
updateOrder={finalizeUnitOrder(section, subsection)}
|
||||||
|
>
|
||||||
|
{subsection.childInfo.children.map((unit, unitIndex) => (
|
||||||
|
<UnitCard
|
||||||
|
key={unit.id}
|
||||||
|
unit={unit}
|
||||||
|
subsection={subsection}
|
||||||
|
section={section}
|
||||||
|
isSelfPaced={statusBarData.isSelfPaced}
|
||||||
|
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||||
|
index={unitIndex}
|
||||||
|
canMoveItem={canMoveItem(subsection.childInfo.children)}
|
||||||
|
savingStatus={savingStatus}
|
||||||
|
onOpenPublishModal={openPublishModal}
|
||||||
|
onOpenConfigureModal={openConfigureModal}
|
||||||
|
onOpenDeleteModal={openDeleteModal}
|
||||||
|
onEditSubmit={handleEditSubmit}
|
||||||
|
onDuplicateSubmit={handleDuplicateUnitSubmit}
|
||||||
|
getTitleLink={getUnitUrl}
|
||||||
|
onOrderChange={updateUnitOrderByIndex(
|
||||||
|
sectionIndex,
|
||||||
|
subsectionIndex,
|
||||||
|
section,
|
||||||
|
subsection,
|
||||||
|
subsection.childInfo.children,
|
||||||
|
)}
|
||||||
|
onCopyToClipboardClick={handleCopyToClipboardClick}
|
||||||
|
discussionsSettings={discussionsSettings}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DraggableList>
|
||||||
|
</SubsectionCard>
|
||||||
|
))}
|
||||||
|
</DraggableList>
|
||||||
|
</SectionCard>
|
||||||
|
))}
|
||||||
|
</DraggableList>
|
||||||
|
{courseActions.childAddable && (
|
||||||
|
<Button
|
||||||
|
data-testid="new-section-button"
|
||||||
|
className="mt-4"
|
||||||
|
variant="outline-primary"
|
||||||
|
onClick={handleNewSectionSubmit}
|
||||||
|
iconBefore={IconAdd}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.newSectionButton)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyPlaceholder
|
||||||
|
onCreateNewSection={handleNewSectionSubmit}
|
||||||
|
childAddable={courseActions.childAddable}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Layout.Element>
|
||||||
|
<Layout.Element>
|
||||||
|
<OutlineSideBar courseId={courseId} />
|
||||||
|
</Layout.Element>
|
||||||
|
</Layout>
|
||||||
|
<EnableHighlightsModal
|
||||||
|
isOpen={isEnableHighlightsModalOpen}
|
||||||
|
close={closeEnableHighlightsModal}
|
||||||
|
onEnableHighlightsSubmit={handleEnableHighlightsSubmit}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<HighlightsModal
|
||||||
|
isOpen={isHighlightsModalOpen}
|
||||||
|
onClose={closeHighlightsModal}
|
||||||
|
onSubmit={handleHighlightsFormSubmit}
|
||||||
|
/>
|
||||||
|
<PublishModal
|
||||||
|
isOpen={isPublishModalOpen}
|
||||||
|
onClose={closePublishModal}
|
||||||
|
onPublishSubmit={handlePublishItemSubmit}
|
||||||
|
/>
|
||||||
|
<ConfigureModal
|
||||||
|
isOpen={isConfigureModalOpen}
|
||||||
|
onClose={handleConfigureModalClose}
|
||||||
|
onConfigureSubmit={handleConfigureItemSubmit}
|
||||||
|
/>
|
||||||
|
<DeleteModal
|
||||||
|
isOpen={isDeleteModalOpen}
|
||||||
|
close={closeDeleteModal}
|
||||||
|
onDeleteSubmit={handleDeleteItemSubmit}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
<div className="alert-toast">
|
||||||
|
<ProcessingNotification
|
||||||
|
isShow={isShowProcessingNotification}
|
||||||
|
title={processingNotificationTitle}
|
||||||
|
/>
|
||||||
|
<InternetConnectionAlert
|
||||||
|
isFailed={isInternetConnectionAlertFailed}
|
||||||
|
isQueryPending={savingStatus === RequestStatus.PENDING}
|
||||||
|
onInternetConnectionFailed={handleInternetConnectionFailed}
|
||||||
|
/>
|
||||||
|
{showErrorAlert && (
|
||||||
|
<AlertMessage
|
||||||
|
key={intl.formatMessage(messages.alertErrorTitle)}
|
||||||
|
show={showErrorAlert}
|
||||||
|
variant="danger"
|
||||||
|
icon={WarningIcon}
|
||||||
|
title={intl.formatMessage(messages.alertErrorTitle)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CourseOutline.propTypes = {
|
||||||
|
courseId: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CourseOutline;
|
||||||
13
src/course-outline/CourseOutline.scss
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@import "./header-navigations/HeaderNavigations";
|
||||||
|
@import "./status-bar/StatusBar";
|
||||||
|
@import "./section-card/SectionCard";
|
||||||
|
@import "./subsection-card/SubsectionCard";
|
||||||
|
@import "./unit-card/UnitCard";
|
||||||
|
@import "./card-header/CardHeader";
|
||||||
|
@import "./empty-placeholder/EmptyPlaceholder";
|
||||||
|
@import "./highlights-modal/HighlightsModal";
|
||||||
|
@import "./publish-modal/PublishModal";
|
||||||
|
@import "./configure-modal/ConfigureModal";
|
||||||
|
@import "./drag-helper/ConditionalSortableElement";
|
||||||
|
@import "./xblock-status/XBlockStatus";
|
||||||
|
@import "./paste-button/PasteButton";
|
||||||
1898
src/course-outline/CourseOutline.test.jsx
Normal file
@@ -0,0 +1,1898 @@
|
|||||||
|
import {
|
||||||
|
act, render, waitFor, fireEvent, within,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
import { AppProvider } from '@edx/frontend-platform/react';
|
||||||
|
import { initializeMockApp } from '@edx/frontend-platform';
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { cloneDeep } from 'lodash';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getCourseBestPracticesApiUrl,
|
||||||
|
getCourseLaunchApiUrl,
|
||||||
|
getCourseOutlineIndexApiUrl,
|
||||||
|
getCourseReindexApiUrl,
|
||||||
|
getXBlockApiUrl,
|
||||||
|
getCourseBlockApiUrl,
|
||||||
|
getCourseItemApiUrl,
|
||||||
|
getXBlockBaseApiUrl,
|
||||||
|
getClipboardUrl,
|
||||||
|
} from './data/api';
|
||||||
|
import { RequestStatus } from '../data/constants';
|
||||||
|
import {
|
||||||
|
fetchCourseBestPracticesQuery,
|
||||||
|
fetchCourseLaunchQuery,
|
||||||
|
fetchCourseOutlineIndexQuery,
|
||||||
|
updateCourseSectionHighlightsQuery,
|
||||||
|
} from './data/thunk';
|
||||||
|
import initializeStore from '../store';
|
||||||
|
import {
|
||||||
|
courseOutlineIndexMock,
|
||||||
|
courseOutlineIndexWithoutSections,
|
||||||
|
courseBestPracticesMock,
|
||||||
|
courseLaunchMock,
|
||||||
|
courseSectionMock,
|
||||||
|
courseSubsectionMock,
|
||||||
|
} from './__mocks__';
|
||||||
|
import { executeThunk } from '../utils';
|
||||||
|
import { COURSE_BLOCK_NAMES, VIDEO_SHARING_OPTIONS } from './constants';
|
||||||
|
import CourseOutline from './CourseOutline';
|
||||||
|
import messages from './messages';
|
||||||
|
import headerMessages from './header-navigations/messages';
|
||||||
|
import cardHeaderMessages from './card-header/messages';
|
||||||
|
import enableHighlightsModalMessages from './enable-highlights-modal/messages';
|
||||||
|
import statusBarMessages from './status-bar/messages';
|
||||||
|
import configureModalMessages from './configure-modal/messages';
|
||||||
|
import pasteButtonMessages from './paste-button/messages';
|
||||||
|
import subsectionMessages from './subsection-card/messages';
|
||||||
|
import pageAlertMessages from './page-alerts/messages';
|
||||||
|
|
||||||
|
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
|
||||||
|
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
|
||||||
|
|
||||||
|
let axiosMock;
|
||||||
|
let store;
|
||||||
|
const mockPathname = '/foo-bar';
|
||||||
|
const courseId = '123';
|
||||||
|
const userId = 3;
|
||||||
|
const userPermissionsData = { permissions: [] };
|
||||||
|
|
||||||
|
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useLocation: () => ({
|
||||||
|
pathname: mockPathname,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../help-urls/hooks', () => ({
|
||||||
|
useHelpUrls: () => ({
|
||||||
|
contentHighlights: 'some',
|
||||||
|
visibility: 'some',
|
||||||
|
grading: 'some',
|
||||||
|
outline: 'some',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||||
|
...jest.requireActual('@edx/frontend-platform/i18n'),
|
||||||
|
useIntl: () => ({
|
||||||
|
formatMessage: (message) => message.defaultMessage,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const RootWrapper = () => (
|
||||||
|
<AppProvider store={store}>
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<CourseOutline courseId={courseId} />
|
||||||
|
</IntlProvider>
|
||||||
|
</AppProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('<CourseOutline />', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
initializeMockApp({
|
||||||
|
authenticatedUser: {
|
||||||
|
userId,
|
||||||
|
username: 'abc123',
|
||||||
|
administrator: true,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
store = initializeStore();
|
||||||
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
|
axiosMock
|
||||||
|
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||||
|
.reply(200, courseOutlineIndexMock);
|
||||||
|
axiosMock
|
||||||
|
.onGet(getUserPermissionsEnabledFlagUrl)
|
||||||
|
.reply(200, { enabled: false });
|
||||||
|
axiosMock
|
||||||
|
.onGet(getUserPermissionsUrl(courseId, userId))
|
||||||
|
.reply(200, userPermissionsData);
|
||||||
|
executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||||
|
executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||||
|
await executeThunk(fetchCourseOutlineIndexQuery(courseId), store.dispatch);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('render CourseOutline component correctly', async () => {
|
||||||
|
const { getByText } = render(<RootWrapper />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||||
|
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render permissionDenied if incorrect permissions', async () => {
|
||||||
|
const { getByTestId } = render(<RootWrapper />);
|
||||||
|
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||||
|
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||||
|
expect(getByTestId('permissionDeniedAlert')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check reindex and render success alert is correctly', async () => {
|
||||||
|
const { findByText, findByTestId } = render(<RootWrapper />);
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink))
|
||||||
|
.reply(200);
|
||||||
|
const reindexButton = await findByTestId('course-reindex');
|
||||||
|
fireEvent.click(reindexButton);
|
||||||
|
|
||||||
|
expect(await findByText(messages.alertSuccessDescription.defaultMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check video sharing option udpates correctly', async () => {
|
||||||
|
const { findByLabelText } = render(<RootWrapper />);
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPost(getCourseBlockApiUrl(courseId), {
|
||||||
|
metadata: {
|
||||||
|
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.reply(200);
|
||||||
|
const optionDropdown = await findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage);
|
||||||
|
await act(
|
||||||
|
async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
|
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
|
||||||
|
metadata: {
|
||||||
|
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check video sharing option shows error on failure', async () => {
|
||||||
|
const { findByLabelText, queryByRole } = render(<RootWrapper />);
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPost(getCourseBlockApiUrl(courseId), {
|
||||||
|
metadata: {
|
||||||
|
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.reply(500);
|
||||||
|
const optionDropdown = await findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage);
|
||||||
|
await act(
|
||||||
|
async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
|
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
|
||||||
|
metadata: {
|
||||||
|
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const alertElement = queryByRole('alert');
|
||||||
|
expect(alertElement).toHaveTextContent(
|
||||||
|
pageAlertMessages.alertFailedGeneric.defaultMessage,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('render error alert after failed reindex correctly', async () => {
|
||||||
|
const { findByText, findByTestId } = render(<RootWrapper />);
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink))
|
||||||
|
.reply(500);
|
||||||
|
const reindexButton = await findByTestId('course-reindex');
|
||||||
|
await act(async () => fireEvent.click(reindexButton));
|
||||||
|
|
||||||
|
expect(await findByText(messages.alertErrorTitle.defaultMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds new section correctly', async () => {
|
||||||
|
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
|
||||||
|
let elements = await findAllByTestId('section-card');
|
||||||
|
window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||||
|
top: 0,
|
||||||
|
bottom: 4000,
|
||||||
|
}));
|
||||||
|
expect(elements.length).toBe(4);
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPost(getXBlockBaseApiUrl())
|
||||||
|
.reply(200, {
|
||||||
|
locator: courseSectionMock.id,
|
||||||
|
});
|
||||||
|
axiosMock
|
||||||
|
.onGet(getXBlockApiUrl(courseSectionMock.id))
|
||||||
|
.reply(200, courseSectionMock);
|
||||||
|
const newSectionButton = await findByTestId('new-section-button');
|
||||||
|
await act(async () => fireEvent.click(newSectionButton));
|
||||||
|
|
||||||
|
elements = await findAllByTestId('section-card');
|
||||||
|
expect(elements.length).toBe(5);
|
||||||
|
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds new subsection correctly', async () => {
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
const [section] = await findAllByTestId('section-card');
|
||||||
|
let subsections = await within(section).findAllByTestId('subsection-card');
|
||||||
|
expect(subsections.length).toBe(2);
|
||||||
|
window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||||
|
top: 0,
|
||||||
|
bottom: 4000,
|
||||||
|
}));
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPost(getXBlockBaseApiUrl())
|
||||||
|
.reply(200, {
|
||||||
|
locator: courseSubsectionMock.id,
|
||||||
|
});
|
||||||
|
axiosMock
|
||||||
|
.onGet(getXBlockApiUrl(courseSubsectionMock.id))
|
||||||
|
.reply(200, courseSubsectionMock);
|
||||||
|
const newSubsectionButton = await within(section).findByTestId('new-subsection-button');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(newSubsectionButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
subsections = await within(section).findAllByTestId('subsection-card');
|
||||||
|
expect(subsections.length).toBe(3);
|
||||||
|
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds new unit correctly', async () => {
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
const [sectionElement] = await findAllByTestId('section-card');
|
||||||
|
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||||
|
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||||
|
fireEvent.click(expandBtn);
|
||||||
|
const units = await within(subsectionElement).findAllByTestId('unit-card');
|
||||||
|
expect(units.length).toBe(1);
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPost(getXBlockBaseApiUrl())
|
||||||
|
.reply(200, {
|
||||||
|
locator: 'some',
|
||||||
|
});
|
||||||
|
const newUnitButton = await within(subsectionElement).findByTestId('new-unit-button');
|
||||||
|
await act(async () => fireEvent.click(newUnitButton));
|
||||||
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
|
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||||
|
const [subsection] = section.childInfo.children;
|
||||||
|
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
|
||||||
|
parent_locator: subsection.id,
|
||||||
|
category: COURSE_BLOCK_NAMES.vertical.id,
|
||||||
|
display_name: COURSE_BLOCK_NAMES.vertical.name,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('render checklist value correctly', async () => {
|
||||||
|
const { getByText } = render(<RootWrapper />);
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onGet(getCourseBestPracticesApiUrl({
|
||||||
|
courseId, excludeGraded: true, all: true,
|
||||||
|
}))
|
||||||
|
.reply(200, courseBestPracticesMock);
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onGet(getCourseLaunchApiUrl({
|
||||||
|
courseId, gradedOnly: true, validateOras: true, all: true,
|
||||||
|
}))
|
||||||
|
.reply(200, courseLaunchMock);
|
||||||
|
|
||||||
|
await executeThunk(fetchCourseLaunchQuery({
|
||||||
|
courseId, gradedOnly: true, validateOras: true, all: true,
|
||||||
|
}), store.dispatch);
|
||||||
|
await executeThunk(fetchCourseBestPracticesQuery({
|
||||||
|
courseId, excludeGraded: true, all: true,
|
||||||
|
}), store.dispatch);
|
||||||
|
|
||||||
|
expect(getByText('4/9 completed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check highlights are enabled after enable highlights query is successful', async () => {
|
||||||
|
const { findByTestId, findByText } = render(<RootWrapper />);
|
||||||
|
|
||||||
|
axiosMock.reset();
|
||||||
|
axiosMock
|
||||||
|
.onPost(getCourseBlockApiUrl(courseId), {
|
||||||
|
publish: 'republish',
|
||||||
|
metadata: {
|
||||||
|
highlights_enabled_for_messaging: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.reply(200);
|
||||||
|
axiosMock
|
||||||
|
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||||
|
.reply(200, {
|
||||||
|
...courseOutlineIndexMock,
|
||||||
|
courseStructure: {
|
||||||
|
...courseOutlineIndexMock.courseStructure,
|
||||||
|
highlightsEnabledForMessaging: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const enableButton = await findByTestId('highlights-enable-button');
|
||||||
|
fireEvent.click(enableButton);
|
||||||
|
const saveButton = await findByText(enableHighlightsModalMessages.submitButton.defaultMessage);
|
||||||
|
await act(async () => fireEvent.click(saveButton));
|
||||||
|
expect(await findByTestId('highlights-enabled-span')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expand and collapse subsections, after click on subheader buttons', async () => {
|
||||||
|
const { queryAllByTestId, findByText } = render(<RootWrapper />);
|
||||||
|
|
||||||
|
const collapseBtn = await findByText(headerMessages.collapseAllButton.defaultMessage);
|
||||||
|
expect(collapseBtn).toBeInTheDocument();
|
||||||
|
fireEvent.click(collapseBtn);
|
||||||
|
|
||||||
|
const expandBtn = await findByText(headerMessages.expandAllButton.defaultMessage);
|
||||||
|
expect(expandBtn).toBeInTheDocument();
|
||||||
|
fireEvent.click(expandBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const cardSubsections = queryAllByTestId('section-card__subsections');
|
||||||
|
cardSubsections.forEach(element => expect(element).toBeVisible());
|
||||||
|
|
||||||
|
fireEvent.click(collapseBtn);
|
||||||
|
cardSubsections.forEach(element => expect(element).not.toBeVisible());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('render CourseOutline component without sections correctly', async () => {
|
||||||
|
axiosMock
|
||||||
|
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||||
|
.reply(200, courseOutlineIndexWithoutSections);
|
||||||
|
|
||||||
|
const { getByTestId } = render(<RootWrapper />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByTestId('empty-placeholder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('render configuration alerts and check dismiss query', async () => {
|
||||||
|
axiosMock
|
||||||
|
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||||
|
.reply(200, {
|
||||||
|
...courseOutlineIndexMock,
|
||||||
|
notificationDismissUrl: '/some/url',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { findByRole } = render(<RootWrapper />);
|
||||||
|
expect(await findByRole('alert')).toBeInTheDocument();
|
||||||
|
const dismissBtn = await findByRole('button', { name: 'Dismiss' });
|
||||||
|
axiosMock
|
||||||
|
.onDelete('/some/url')
|
||||||
|
.reply(204);
|
||||||
|
fireEvent.click(dismissBtn);
|
||||||
|
|
||||||
|
expect(axiosMock.history.delete.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check edit title works for section, subsection and unit', async () => {
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
const checkEditTitle = async (section, element, item, newName, elementName) => {
|
||||||
|
axiosMock.reset();
|
||||||
|
axiosMock
|
||||||
|
.onPost(getCourseItemApiUrl(item.id, {
|
||||||
|
metadata: {
|
||||||
|
display_name: newName,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
// mock section, subsection and unit name and check within the elements.
|
||||||
|
// this is done to avoid adding conditions to this mock.
|
||||||
|
axiosMock
|
||||||
|
.onGet(getXBlockApiUrl(section.id))
|
||||||
|
.reply(200, {
|
||||||
|
...section,
|
||||||
|
display_name: newName,
|
||||||
|
childInfo: {
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
...section.childInfo.children[0],
|
||||||
|
display_name: newName,
|
||||||
|
childInfo: {
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
...section.childInfo.children[0].childInfo.children[0],
|
||||||
|
display_name: newName,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const editButton = await within(element).findByTestId(`${elementName}-edit-button`);
|
||||||
|
fireEvent.click(editButton);
|
||||||
|
const editField = await within(element).findByTestId(`${elementName}-edit-field`);
|
||||||
|
fireEvent.change(editField, { target: { value: newName } });
|
||||||
|
await act(async () => fireEvent.blur(editField));
|
||||||
|
expect(
|
||||||
|
axiosMock.history.post[axiosMock.history.post.length - 1].data,
|
||||||
|
`Failed for ${elementName}!`,
|
||||||
|
).toBe(JSON.stringify({
|
||||||
|
metadata: {
|
||||||
|
display_name: newName,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const results = await within(element).findAllByText(newName);
|
||||||
|
expect(results.length, `Failed for ${elementName}!`).toBeGreaterThan(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// check section
|
||||||
|
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||||
|
const [sectionElement] = await findAllByTestId('section-card');
|
||||||
|
await checkEditTitle(section, sectionElement, section, 'New section name', 'section');
|
||||||
|
|
||||||
|
// check subsection
|
||||||
|
const [subsection] = section.childInfo.children;
|
||||||
|
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||||
|
await checkEditTitle(section, subsectionElement, subsection, 'New subsection name', 'subsection');
|
||||||
|
|
||||||
|
// check unit
|
||||||
|
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||||
|
fireEvent.click(expandBtn);
|
||||||
|
const [unit] = subsection.childInfo.children;
|
||||||
|
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||||
|
await checkEditTitle(section, unitElement, unit, 'New unit name', 'unit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check whether section, subsection and unit is deleted when corresponding delete button is clicked', async () => {
|
||||||
|
const { findAllByTestId, findByTestId, queryByText } = render(<RootWrapper />);
|
||||||
|
// get section, subsection and unit
|
||||||
|
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||||
|
const [sectionElement] = await findAllByTestId('section-card');
|
||||||
|
const [subsection] = section.childInfo.children;
|
||||||
|
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||||
|
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||||
|
fireEvent.click(expandBtn);
|
||||||
|
const [unit] = subsection.childInfo.children;
|
||||||
|
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||||
|
|
||||||
|
const checkDeleteBtn = async (item, element, elementName) => {
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryByText(item.displayName), `Failed for ${elementName}!`).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
axiosMock.onDelete(getCourseItemApiUrl(item.id)).reply(200);
|
||||||
|
|
||||||
|
const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`);
|
||||||
|
fireEvent.click(menu);
|
||||||
|
const deleteButton = await within(element).findByTestId(`${elementName}-card-header__menu-delete-button`);
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
const confirmButton = await findByTestId('delete-confirm-button');
|
||||||
|
await act(async () => fireEvent.click(confirmButton));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryByText(item.displayName), `Failed for ${elementName}!`).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// delete unit, subsection and then section in order.
|
||||||
|
// check unit
|
||||||
|
await checkDeleteBtn(unit, unitElement, 'unit');
|
||||||
|
// check subsection
|
||||||
|
await checkDeleteBtn(subsection, subsectionElement, 'subsection');
|
||||||
|
// check section
|
||||||
|
await checkDeleteBtn(section, sectionElement, 'section');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check whether section, subsection and unit is duplicated successfully', async () => {
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
// get section, subsection and unit
|
||||||
|
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||||
|
const [sectionElement] = await findAllByTestId('section-card');
|
||||||
|
const [subsection] = section.childInfo.children;
|
||||||
|
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||||
|
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||||
|
fireEvent.click(expandBtn);
|
||||||
|
const [unit] = subsection.childInfo.children;
|
||||||
|
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||||
|
|
||||||
|
const checkDuplicateBtn = async (item, parentElement, element, elementName, expectedLength) => {
|
||||||
|
// baseline
|
||||||
|
if (parentElement) {
|
||||||
|
expect(
|
||||||
|
await within(parentElement).findAllByTestId(`${elementName}-card`),
|
||||||
|
`Failed for ${elementName}!`,
|
||||||
|
).toHaveLength(expectedLength - 1);
|
||||||
|
} else {
|
||||||
|
expect(
|
||||||
|
await findAllByTestId(`${elementName}-card`),
|
||||||
|
`Failed for ${elementName}!`,
|
||||||
|
).toHaveLength(expectedLength - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicatedItemId = item.id + elementName;
|
||||||
|
axiosMock
|
||||||
|
.onPost(getXBlockBaseApiUrl())
|
||||||
|
.reply(200, {
|
||||||
|
locator: duplicatedItemId,
|
||||||
|
});
|
||||||
|
if (elementName === 'section') {
|
||||||
|
section.id = duplicatedItemId;
|
||||||
|
} else if (elementName === 'subsection') {
|
||||||
|
section.childInfo.children = [...section.childInfo.children, { ...subsection, id: duplicatedItemId }];
|
||||||
|
} else if (elementName === 'unit') {
|
||||||
|
subsection.childInfo.children = [...subsection.childInfo.children, { ...unit, id: duplicatedItemId }];
|
||||||
|
section.childInfo.children = [subsection, ...section.childInfo.children.slice(1)];
|
||||||
|
}
|
||||||
|
axiosMock
|
||||||
|
.onGet(getXBlockApiUrl(section.id))
|
||||||
|
.reply(200, {
|
||||||
|
...section,
|
||||||
|
});
|
||||||
|
|
||||||
|
const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`);
|
||||||
|
fireEvent.click(menu);
|
||||||
|
const duplicateButton = await within(element).findByTestId(`${elementName}-card-header__menu-duplicate-button`);
|
||||||
|
await act(async () => fireEvent.click(duplicateButton));
|
||||||
|
if (parentElement) {
|
||||||
|
expect(
|
||||||
|
await within(parentElement).findAllByTestId(`${elementName}-card`),
|
||||||
|
`Failed for ${elementName}!`,
|
||||||
|
).toHaveLength(expectedLength);
|
||||||
|
} else {
|
||||||
|
expect(
|
||||||
|
await findAllByTestId(`${elementName}-card`),
|
||||||
|
`Failed for ${elementName}!`,
|
||||||
|
).toHaveLength(expectedLength);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// duplicate unit, subsection and then section in order.
|
||||||
|
// check unit
|
||||||
|
await checkDuplicateBtn(unit, subsectionElement, unitElement, 'unit', 2);
|
||||||
|
// check subsection
|
||||||
|
await checkDuplicateBtn(subsection, sectionElement, subsectionElement, 'subsection', 3);
|
||||||
|
// check section
|
||||||
|
await checkDuplicateBtn(section, null, sectionElement, 'section', 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check section, subsection & unit is published when publish button is clicked', async () => {
|
||||||
|
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
|
||||||
|
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||||
|
const [sectionElement] = await findAllByTestId('section-card');
|
||||||
|
const [subsection] = section.childInfo.children;
|
||||||
|
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||||
|
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||||
|
fireEvent.click(expandBtn);
|
||||||
|
const [unit] = subsection.childInfo.children;
|
||||||
|
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||||
|
|
||||||
|
const checkPublishBtn = async (item, element, elementName) => {
|
||||||
|
expect(
|
||||||
|
(await within(element).getAllByRole('status'))[0],
|
||||||
|
`Failed for ${elementName}!`,
|
||||||
|
).toHaveTextContent(cardHeaderMessages.statusBadgeDraft.defaultMessage);
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPost(getCourseItemApiUrl(item.id), {
|
||||||
|
publish: 'make_public',
|
||||||
|
})
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
|
||||||
|
let mockReturnValue = {
|
||||||
|
...section,
|
||||||
|
childInfo: {
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
...section.childInfo.children[0],
|
||||||
|
published: true,
|
||||||
|
},
|
||||||
|
...section.childInfo.children.slice(1),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (elementName === 'unit') {
|
||||||
|
mockReturnValue = {
|
||||||
|
...section,
|
||||||
|
childInfo: {
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
...section.childInfo.children[0],
|
||||||
|
childInfo: {
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
...section.childInfo.children[0].childInfo.children[0],
|
||||||
|
published: true,
|
||||||
|
},
|
||||||
|
...section.childInfo.children[0].childInfo.children.slice(1),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...section.childInfo.children.slice(1),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
axiosMock
|
||||||
|
.onGet(getXBlockApiUrl(section.id))
|
||||||
|
.reply(200, mockReturnValue);
|
||||||
|
|
||||||
|
const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`);
|
||||||
|
fireEvent.click(menu);
|
||||||
|
const publishButton = await within(element).findByTestId(`${elementName}-card-header__menu-publish-button`);
|
||||||
|
await act(async () => fireEvent.click(publishButton));
|
||||||
|
const confirmButton = await findByTestId('publish-confirm-button');
|
||||||
|
await act(async () => fireEvent.click(confirmButton));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await within(element).getAllByRole('status'))[0],
|
||||||
|
`Failed for ${elementName}!`,
|
||||||
|
).toHaveTextContent(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
// publish unit, subsection and then section in order.
|
||||||
|
// check unit
|
||||||
|
await checkPublishBtn(unit, unitElement, 'unit');
|
||||||
|
// check subsection
|
||||||
|
await checkPublishBtn(subsection, subsectionElement, 'subsection');
|
||||||
|
// section doesn't display badges
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check configure modal for section', async () => {
|
||||||
|
const { findByTestId, findAllByTestId } = render(<RootWrapper />);
|
||||||
|
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||||
|
const newReleaseDateIso = '2025-09-10T22:00:00Z';
|
||||||
|
const newReleaseDate = '09/10/2025';
|
||||||
|
axiosMock
|
||||||
|
.onPost(getCourseItemApiUrl(section.id), {
|
||||||
|
publish: 'republish',
|
||||||
|
metadata: {
|
||||||
|
visible_to_staff_only: true,
|
||||||
|
start: newReleaseDateIso,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onGet(getXBlockApiUrl(section.id))
|
||||||
|
.reply(200, {
|
||||||
|
...section,
|
||||||
|
start: newReleaseDateIso,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [firstSection] = await findAllByTestId('section-card');
|
||||||
|
|
||||||
|
const sectionDropdownButton = await within(firstSection).findByTestId('section-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(sectionDropdownButton));
|
||||||
|
const configureBtn = await within(firstSection).findByTestId('section-card-header__menu-configure-button');
|
||||||
|
await act(async () => fireEvent.click(configureBtn));
|
||||||
|
let releaseDateStack = await findByTestId('release-date-stack');
|
||||||
|
let releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||||
|
expect(releaseDatePicker).toHaveValue('08/10/2023');
|
||||||
|
|
||||||
|
await act(async () => fireEvent.change(releaseDatePicker, { target: { value: newReleaseDate } }));
|
||||||
|
expect(releaseDatePicker).toHaveValue(newReleaseDate);
|
||||||
|
const saveButton = await findByTestId('configure-save-button');
|
||||||
|
await act(async () => fireEvent.click(saveButton));
|
||||||
|
|
||||||
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
|
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
|
||||||
|
publish: 'republish',
|
||||||
|
metadata: {
|
||||||
|
visible_to_staff_only: true,
|
||||||
|
start: newReleaseDateIso,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await act(async () => fireEvent.click(sectionDropdownButton));
|
||||||
|
await act(async () => fireEvent.click(configureBtn));
|
||||||
|
releaseDateStack = await findByTestId('release-date-stack');
|
||||||
|
releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||||
|
expect(releaseDatePicker).toHaveValue(newReleaseDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check configure modal for subsection', async () => {
|
||||||
|
const {
|
||||||
|
findAllByTestId,
|
||||||
|
findByTestId,
|
||||||
|
} = render(<RootWrapper />);
|
||||||
|
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||||
|
const [subsection] = section.childInfo.children;
|
||||||
|
const expectedRequestData = {
|
||||||
|
publish: 'republish',
|
||||||
|
graderType: 'Homework',
|
||||||
|
isPrereq: false,
|
||||||
|
prereqMinScore: 100,
|
||||||
|
prereqMinCompletion: 100,
|
||||||
|
metadata: {
|
||||||
|
visible_to_staff_only: null,
|
||||||
|
due: '2025-09-10T05:00:00Z',
|
||||||
|
hide_after_due: true,
|
||||||
|
show_correctness: 'always',
|
||||||
|
is_practice_exam: false,
|
||||||
|
is_time_limited: true,
|
||||||
|
is_proctored_enabled: false,
|
||||||
|
exam_review_rules: '',
|
||||||
|
default_time_limit_minutes: 3270,
|
||||||
|
is_onboarding_exam: false,
|
||||||
|
start: '2025-08-10T00:00:00Z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPost(getCourseItemApiUrl(subsection.id), expectedRequestData)
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
|
||||||
|
const [currentSection] = await findAllByTestId('section-card');
|
||||||
|
const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card');
|
||||||
|
const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button');
|
||||||
|
|
||||||
|
subsection.start = expectedRequestData.metadata.start;
|
||||||
|
subsection.due = expectedRequestData.metadata.due;
|
||||||
|
subsection.format = expectedRequestData.graderType;
|
||||||
|
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
|
||||||
|
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
|
||||||
|
subsection.hideAfterDue = expectedRequestData.metadata.hideAfterDue;
|
||||||
|
section.childInfo.children[0] = subsection;
|
||||||
|
axiosMock
|
||||||
|
.onGet(getXBlockApiUrl(section.id))
|
||||||
|
.reply(200, section);
|
||||||
|
|
||||||
|
fireEvent.click(subsectionDropdownButton);
|
||||||
|
const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button');
|
||||||
|
fireEvent.click(configureBtn);
|
||||||
|
|
||||||
|
// update fields
|
||||||
|
let configureModal = await findByTestId('configure-modal');
|
||||||
|
expect(await within(configureModal).findByText(expectedRequestData.graderType)).toBeInTheDocument();
|
||||||
|
let releaseDateStack = await within(configureModal).findByTestId('release-date-stack');
|
||||||
|
let releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||||
|
fireEvent.change(releaseDatePicker, { target: { value: '08/10/2025' } });
|
||||||
|
let releaseDateTimePicker = await within(releaseDateStack).findByPlaceholderText('HH:MM');
|
||||||
|
fireEvent.change(releaseDateTimePicker, { target: { value: '00:00' } });
|
||||||
|
let dueDateStack = await within(configureModal).findByTestId('due-date-stack');
|
||||||
|
let dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||||
|
fireEvent.change(dueDatePicker, { target: { value: '09/10/2025' } });
|
||||||
|
let dueDateTimePicker = await within(dueDateStack).findByPlaceholderText('HH:MM');
|
||||||
|
fireEvent.change(dueDateTimePicker, { target: { value: '05:00' } });
|
||||||
|
let graderTypeDropdown = await within(configureModal).findByTestId('grader-type-select');
|
||||||
|
fireEvent.change(graderTypeDropdown, { target: { value: expectedRequestData.graderType } });
|
||||||
|
|
||||||
|
// visibility tab
|
||||||
|
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
|
||||||
|
fireEvent.click(visibilityTab);
|
||||||
|
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
|
||||||
|
fireEvent.click(visibilityRadioButtons[1]);
|
||||||
|
|
||||||
|
let advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||||
|
fireEvent.click(advancedTab);
|
||||||
|
let radioButtons = await within(configureModal).findAllByRole('radio');
|
||||||
|
fireEvent.click(radioButtons[1]);
|
||||||
|
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||||
|
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||||
|
fireEvent.change(hours, { target: { value: '54:30' } });
|
||||||
|
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||||
|
await act(async () => fireEvent.click(saveButton));
|
||||||
|
|
||||||
|
// verify request
|
||||||
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
|
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
|
||||||
|
|
||||||
|
// reopen modal and check values
|
||||||
|
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||||
|
await act(async () => fireEvent.click(configureBtn));
|
||||||
|
|
||||||
|
configureModal = await findByTestId('configure-modal');
|
||||||
|
releaseDateStack = await within(configureModal).findByTestId('release-date-stack');
|
||||||
|
releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||||
|
expect(releaseDatePicker).toHaveValue('08/10/2025');
|
||||||
|
releaseDateTimePicker = await within(releaseDateStack).findByPlaceholderText('HH:MM');
|
||||||
|
expect(releaseDateTimePicker).toHaveValue('00:00');
|
||||||
|
dueDateStack = await await within(configureModal).findByTestId('due-date-stack');
|
||||||
|
dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||||
|
expect(dueDatePicker).toHaveValue('09/10/2025');
|
||||||
|
dueDateTimePicker = await within(dueDateStack).findByPlaceholderText('HH:MM');
|
||||||
|
expect(dueDateTimePicker).toHaveValue('05:00');
|
||||||
|
graderTypeDropdown = await within(configureModal).findByTestId('grader-type-select');
|
||||||
|
expect(graderTypeDropdown).toHaveValue(expectedRequestData.graderType);
|
||||||
|
|
||||||
|
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||||
|
fireEvent.click(advancedTab);
|
||||||
|
radioButtons = await within(configureModal).findAllByRole('radio');
|
||||||
|
expect(radioButtons[0]).toHaveProperty('checked', false);
|
||||||
|
expect(radioButtons[1]).toHaveProperty('checked', true);
|
||||||
|
hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||||
|
hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||||
|
expect(hours).toHaveValue('54:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check prereq and proctoring settings in configure modal for subsection', async () => {
|
||||||
|
const {
|
||||||
|
findAllByTestId,
|
||||||
|
findByTestId,
|
||||||
|
} = render(<RootWrapper />);
|
||||||
|
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||||
|
const [subsection, secondSubsection] = section.childInfo.children;
|
||||||
|
const expectedRequestData = {
|
||||||
|
publish: 'republish',
|
||||||
|
graderType: 'notgraded',
|
||||||
|
isPrereq: true,
|
||||||
|
prereqUsageKey: secondSubsection.id,
|
||||||
|
prereqMinScore: 80,
|
||||||
|
prereqMinCompletion: 90,
|
||||||
|
metadata: {
|
||||||
|
visible_to_staff_only: true,
|
||||||
|
due: '',
|
||||||
|
hide_after_due: false,
|
||||||
|
show_correctness: 'always',
|
||||||
|
is_practice_exam: false,
|
||||||
|
is_time_limited: true,
|
||||||
|
is_proctored_enabled: true,
|
||||||
|
exam_review_rules: 'some rules for proctored exams',
|
||||||
|
default_time_limit_minutes: 30,
|
||||||
|
is_onboarding_exam: false,
|
||||||
|
start: '1970-01-01T05:00:00Z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPost(getCourseItemApiUrl(subsection.id), expectedRequestData)
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
|
||||||
|
const [currentSection] = await findAllByTestId('section-card');
|
||||||
|
const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card');
|
||||||
|
const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button');
|
||||||
|
|
||||||
|
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
|
||||||
|
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
|
||||||
|
subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled;
|
||||||
|
subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam;
|
||||||
|
subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam;
|
||||||
|
subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules;
|
||||||
|
subsection.isPrereq = expectedRequestData.isPrereq;
|
||||||
|
subsection.prereq = expectedRequestData.prereqUsageKey;
|
||||||
|
subsection.prereqMinScore = expectedRequestData.prereqMinScore;
|
||||||
|
subsection.prereqMinCompletion = expectedRequestData.prereqMinCompletion;
|
||||||
|
section.childInfo.children[0] = subsection;
|
||||||
|
axiosMock
|
||||||
|
.onGet(getXBlockApiUrl(section.id))
|
||||||
|
.reply(200, section);
|
||||||
|
|
||||||
|
fireEvent.click(subsectionDropdownButton);
|
||||||
|
const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button');
|
||||||
|
fireEvent.click(configureBtn);
|
||||||
|
|
||||||
|
// update fields
|
||||||
|
let configureModal = await findByTestId('configure-modal');
|
||||||
|
let advancedTab = await within(configureModal).findByRole(
|
||||||
|
'tab',
|
||||||
|
{ name: configureModalMessages.advancedTabTitle.defaultMessage },
|
||||||
|
);
|
||||||
|
|
||||||
|
// visibility tab
|
||||||
|
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
|
||||||
|
fireEvent.click(visibilityTab);
|
||||||
|
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
|
||||||
|
fireEvent.click(visibilityRadioButtons[2]);
|
||||||
|
|
||||||
|
fireEvent.click(advancedTab);
|
||||||
|
let radioButtons = await within(configureModal).findAllByRole('radio');
|
||||||
|
fireEvent.click(radioButtons[2]);
|
||||||
|
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||||
|
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||||
|
fireEvent.change(hours, { target: { value: '00:30' } });
|
||||||
|
// select a prerequisite
|
||||||
|
const prereqSelect = await within(configureModal).findByRole('combobox');
|
||||||
|
fireEvent.change(prereqSelect, { target: { value: expectedRequestData.prereqUsageKey } });
|
||||||
|
|
||||||
|
// update minimum score and completion percentage
|
||||||
|
let prereqMinScoreInput = await within(configureModal).findByLabelText(
|
||||||
|
configureModalMessages.minScoreLabel.defaultMessage,
|
||||||
|
);
|
||||||
|
fireEvent.change(prereqMinScoreInput, { target: { value: expectedRequestData.prereqMinScore } });
|
||||||
|
let prereqMinCompletionInput = await within(configureModal).findByLabelText(
|
||||||
|
configureModalMessages.minCompletionLabel.defaultMessage,
|
||||||
|
);
|
||||||
|
fireEvent.change(prereqMinCompletionInput, { target: { value: expectedRequestData.prereqMinCompletion } });
|
||||||
|
|
||||||
|
// enable this subsection to be used as prerequisite by other subsections
|
||||||
|
let prereqCheckbox = await within(configureModal).findByLabelText(
|
||||||
|
configureModalMessages.prereqCheckboxLabel.defaultMessage,
|
||||||
|
);
|
||||||
|
fireEvent.click(prereqCheckbox);
|
||||||
|
|
||||||
|
// fill some rules for proctored exams
|
||||||
|
let examsRulesInput = await within(configureModal).findByLabelText(
|
||||||
|
configureModalMessages.reviewRulesLabel.defaultMessage,
|
||||||
|
);
|
||||||
|
fireEvent.change(examsRulesInput, { target: { value: expectedRequestData.metadata.exam_review_rules } });
|
||||||
|
|
||||||
|
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||||
|
await act(async () => fireEvent.click(saveButton));
|
||||||
|
|
||||||
|
// verify request
|
||||||
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
|
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
|
||||||
|
|
||||||
|
// reopen modal and check values
|
||||||
|
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||||
|
await act(async () => fireEvent.click(configureBtn));
|
||||||
|
|
||||||
|
configureModal = await findByTestId('configure-modal');
|
||||||
|
advancedTab = await within(configureModal).findByRole('tab', {
|
||||||
|
name: configureModalMessages.advancedTabTitle.defaultMessage,
|
||||||
|
});
|
||||||
|
fireEvent.click(advancedTab);
|
||||||
|
radioButtons = await within(configureModal).findAllByRole('radio');
|
||||||
|
expect(radioButtons[0]).toHaveProperty('checked', false);
|
||||||
|
expect(radioButtons[1]).toHaveProperty('checked', false);
|
||||||
|
expect(radioButtons[2]).toHaveProperty('checked', true);
|
||||||
|
hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||||
|
hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||||
|
expect(hours).toHaveValue('00:30');
|
||||||
|
prereqCheckbox = await within(configureModal).findByLabelText(
|
||||||
|
configureModalMessages.prereqCheckboxLabel.defaultMessage,
|
||||||
|
);
|
||||||
|
expect(prereqCheckbox).toBeChecked();
|
||||||
|
const prereqSelectOption = await within(configureModal).findByRole('option', { selected: true });
|
||||||
|
expect(prereqSelectOption).toHaveAttribute('value', expectedRequestData.prereqUsageKey);
|
||||||
|
examsRulesInput = await within(configureModal).findByLabelText(
|
||||||
|
configureModalMessages.reviewRulesLabel.defaultMessage,
|
||||||
|
);
|
||||||
|
expect(examsRulesInput).toHaveTextContent(expectedRequestData.metadata.exam_review_rules);
|
||||||
|
|
||||||
|
prereqMinScoreInput = await within(configureModal).findByLabelText(
|
||||||
|
configureModalMessages.minScoreLabel.defaultMessage,
|
||||||
|
);
|
||||||
|
expect(prereqMinScoreInput).toHaveAttribute('value', `${expectedRequestData.prereqMinScore}`);
|
||||||
|
prereqMinCompletionInput = await within(configureModal).findByLabelText(
|
||||||
|
configureModalMessages.minCompletionLabel.defaultMessage,
|
||||||
|
);
|
||||||
|
expect(prereqMinCompletionInput).toHaveAttribute('value', `${expectedRequestData.prereqMinCompletion}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check practice proctoring settings in configure modal', async () => {
|
||||||
|
const {
|
||||||
|
findAllByTestId,
|
||||||
|
findByTestId,
|
||||||
|
} = render(<RootWrapper />);
|
||||||
|
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||||
|
const [subsection] = section.childInfo.children;
|
||||||
|
const expectedRequestData = {
|
||||||
|
publish: 'republish',
|
||||||
|
graderType: 'notgraded',
|
||||||
|
isPrereq: false,
|
||||||
|
prereqMinScore: 100,
|
||||||
|
prereqMinCompletion: 100,
|
||||||
|
metadata: {
|
||||||
|
visible_to_staff_only: null,
|
||||||
|
due: '',
|
||||||
|
hide_after_due: false,
|
||||||
|
show_correctness: 'never',
|
||||||
|
is_practice_exam: true,
|
||||||
|
is_time_limited: true,
|
||||||
|
is_proctored_enabled: true,
|
||||||
|
exam_review_rules: '',
|
||||||
|
default_time_limit_minutes: 30,
|
||||||
|
is_onboarding_exam: false,
|
||||||
|
start: '1970-01-01T05:00:00Z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPost(getCourseItemApiUrl(subsection.id), expectedRequestData)
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
|
||||||
|
const [currentSection] = await findAllByTestId('section-card');
|
||||||
|
const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card');
|
||||||
|
const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button');
|
||||||
|
|
||||||
|
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
|
||||||
|
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
|
||||||
|
subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled;
|
||||||
|
subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam;
|
||||||
|
subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam;
|
||||||
|
subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules;
|
||||||
|
section.childInfo.children[0] = subsection;
|
||||||
|
axiosMock
|
||||||
|
.onGet(getXBlockApiUrl(section.id))
|
||||||
|
.reply(200, section);
|
||||||
|
|
||||||
|
fireEvent.click(subsectionDropdownButton);
|
||||||
|
const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button');
|
||||||
|
fireEvent.click(configureBtn);
|
||||||
|
|
||||||
|
// update fields
|
||||||
|
let configureModal = await findByTestId('configure-modal');
|
||||||
|
let advancedTab = await within(configureModal).findByRole(
|
||||||
|
'tab',
|
||||||
|
{ name: configureModalMessages.advancedTabTitle.defaultMessage },
|
||||||
|
);
|
||||||
|
// visibility tab
|
||||||
|
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
|
||||||
|
fireEvent.click(visibilityTab);
|
||||||
|
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
|
||||||
|
fireEvent.click(visibilityRadioButtons[4]);
|
||||||
|
|
||||||
|
// advancedTab
|
||||||
|
fireEvent.click(advancedTab);
|
||||||
|
let radioButtons = await within(configureModal).findAllByRole('radio');
|
||||||
|
fireEvent.click(radioButtons[3]);
|
||||||
|
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||||
|
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||||
|
fireEvent.change(hours, { target: { value: '00:30' } });
|
||||||
|
|
||||||
|
// rules box should not be visible
|
||||||
|
expect(within(configureModal).queryByLabelText(
|
||||||
|
configureModalMessages.reviewRulesLabel.defaultMessage,
|
||||||
|
)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||||
|
await act(async () => fireEvent.click(saveButton));
|
||||||
|
|
||||||
|
// verify request
|
||||||
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
|
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
|
||||||
|
|
||||||
|
// reopen modal and check values
|
||||||
|
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||||
|
await act(async () => fireEvent.click(configureBtn));
|
||||||
|
|
||||||
|
configureModal = await findByTestId('configure-modal');
|
||||||
|
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||||
|
fireEvent.click(advancedTab);
|
||||||
|
radioButtons = await within(configureModal).findAllByRole('radio');
|
||||||
|
expect(radioButtons[0]).toHaveProperty('checked', false);
|
||||||
|
expect(radioButtons[1]).toHaveProperty('checked', false);
|
||||||
|
expect(radioButtons[2]).toHaveProperty('checked', false);
|
||||||
|
expect(radioButtons[3]).toHaveProperty('checked', true);
|
||||||
|
hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||||
|
hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||||
|
expect(hours).toHaveValue('00:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check onboarding proctoring settings in configure modal', async () => {
|
||||||
|
const {
|
||||||
|
findAllByTestId,
|
||||||
|
findByTestId,
|
||||||
|
} = render(<RootWrapper />);
|
||||||
|
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||||
|
const [, subsection] = section.childInfo.children;
|
||||||
|
const expectedRequestData = {
|
||||||
|
publish: 'republish',
|
||||||
|
graderType: 'notgraded',
|
||||||
|
isPrereq: true,
|
||||||
|
prereqMinScore: 100,
|
||||||
|
prereqMinCompletion: 100,
|
||||||
|
metadata: {
|
||||||
|
visible_to_staff_only: null,
|
||||||
|
due: '',
|
||||||
|
hide_after_due: false,
|
||||||
|
show_correctness: 'past_due',
|
||||||
|
is_practice_exam: false,
|
||||||
|
is_time_limited: true,
|
||||||
|
is_proctored_enabled: true,
|
||||||
|
exam_review_rules: '',
|
||||||
|
default_time_limit_minutes: 30,
|
||||||
|
is_onboarding_exam: true,
|
||||||
|
start: '2013-02-05T05:00:00Z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPost(getCourseItemApiUrl(subsection.id), expectedRequestData)
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
|
||||||
|
const [currentSection] = await findAllByTestId('section-card');
|
||||||
|
const [, secondSubsection] = await within(currentSection).findAllByTestId('subsection-card');
|
||||||
|
const subsectionDropdownButton = await within(secondSubsection).findByTestId('subsection-card-header__menu-button');
|
||||||
|
|
||||||
|
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
|
||||||
|
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
|
||||||
|
subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled;
|
||||||
|
subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam;
|
||||||
|
subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam;
|
||||||
|
subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules;
|
||||||
|
section.childInfo.children[1] = subsection;
|
||||||
|
axiosMock
|
||||||
|
.onGet(getXBlockApiUrl(section.id))
|
||||||
|
.reply(200, section);
|
||||||
|
|
||||||
|
fireEvent.click(subsectionDropdownButton);
|
||||||
|
const configureBtn = await within(secondSubsection).findByTestId('subsection-card-header__menu-configure-button');
|
||||||
|
fireEvent.click(configureBtn);
|
||||||
|
|
||||||
|
// update fields
|
||||||
|
let configureModal = await findByTestId('configure-modal');
|
||||||
|
// visibility tab
|
||||||
|
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
|
||||||
|
fireEvent.click(visibilityTab);
|
||||||
|
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
|
||||||
|
fireEvent.click(visibilityRadioButtons[5]);
|
||||||
|
|
||||||
|
// advancedTab
|
||||||
|
let advancedTab = await within(configureModal).findByRole(
|
||||||
|
'tab',
|
||||||
|
{ name: configureModalMessages.advancedTabTitle.defaultMessage },
|
||||||
|
);
|
||||||
|
fireEvent.click(advancedTab);
|
||||||
|
let radioButtons = await within(configureModal).findAllByRole('radio');
|
||||||
|
fireEvent.click(radioButtons[3]);
|
||||||
|
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||||
|
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||||
|
fireEvent.change(hours, { target: { value: '00:30' } });
|
||||||
|
|
||||||
|
// rules box should not be visible
|
||||||
|
expect(within(configureModal).queryByLabelText(
|
||||||
|
configureModalMessages.reviewRulesLabel.defaultMessage,
|
||||||
|
)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||||
|
await act(async () => fireEvent.click(saveButton));
|
||||||
|
|
||||||
|
// verify request
|
||||||
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
|
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
|
||||||
|
|
||||||
|
// reopen modal and check values
|
||||||
|
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||||
|
await act(async () => fireEvent.click(configureBtn));
|
||||||
|
|
||||||
|
configureModal = await findByTestId('configure-modal');
|
||||||
|
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||||
|
fireEvent.click(advancedTab);
|
||||||
|
radioButtons = await within(configureModal).findAllByRole('radio');
|
||||||
|
expect(radioButtons[0]).toHaveProperty('checked', false);
|
||||||
|
expect(radioButtons[1]).toHaveProperty('checked', false);
|
||||||
|
expect(radioButtons[2]).toHaveProperty('checked', false);
|
||||||
|
expect(radioButtons[3]).toHaveProperty('checked', true);
|
||||||
|
hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||||
|
hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||||
|
expect(hours).toHaveValue('00:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check no special exam setting in configure modal', async () => {
|
||||||
|
const {
|
||||||
|
findAllByTestId,
|
||||||
|
findByTestId,
|
||||||
|
} = render(<RootWrapper />);
|
||||||
|
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[1]);
|
||||||
|
const [subsection] = section.childInfo.children;
|
||||||
|
const expectedRequestData = {
|
||||||
|
publish: 'republish',
|
||||||
|
graderType: 'notgraded',
|
||||||
|
prereqMinScore: 100,
|
||||||
|
prereqMinCompletion: 100,
|
||||||
|
metadata: {
|
||||||
|
visible_to_staff_only: null,
|
||||||
|
due: '',
|
||||||
|
hide_after_due: false,
|
||||||
|
show_correctness: 'always',
|
||||||
|
is_practice_exam: false,
|
||||||
|
is_time_limited: false,
|
||||||
|
is_proctored_enabled: false,
|
||||||
|
exam_review_rules: '',
|
||||||
|
default_time_limit_minutes: 0,
|
||||||
|
is_onboarding_exam: false,
|
||||||
|
start: '1970-01-01T05:00:00Z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPost(getCourseItemApiUrl(subsection.id), expectedRequestData)
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
|
||||||
|
const [, currentSection] = await findAllByTestId('section-card');
|
||||||
|
const [subsectionElement] = await within(currentSection).findAllByTestId('subsection-card');
|
||||||
|
const subsectionDropdownButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-button');
|
||||||
|
|
||||||
|
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
|
||||||
|
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
|
||||||
|
subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled;
|
||||||
|
subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam;
|
||||||
|
subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam;
|
||||||
|
subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules;
|
||||||
|
section.childInfo.children[0] = subsection;
|
||||||
|
axiosMock
|
||||||
|
.onGet(getXBlockApiUrl(section.id))
|
||||||
|
.reply(200, section);
|
||||||
|
|
||||||
|
fireEvent.click(subsectionDropdownButton);
|
||||||
|
const configureBtn = await within(subsectionElement).findByTestId('subsection-card-header__menu-configure-button');
|
||||||
|
fireEvent.click(configureBtn);
|
||||||
|
|
||||||
|
// update fields
|
||||||
|
let configureModal = await findByTestId('configure-modal');
|
||||||
|
|
||||||
|
// advancedTab
|
||||||
|
let advancedTab = await within(configureModal).findByRole(
|
||||||
|
'tab',
|
||||||
|
{ name: configureModalMessages.advancedTabTitle.defaultMessage },
|
||||||
|
);
|
||||||
|
fireEvent.click(advancedTab);
|
||||||
|
let radioButtons = await within(configureModal).findAllByRole('radio');
|
||||||
|
fireEvent.click(radioButtons[0]);
|
||||||
|
|
||||||
|
// time box should not be visible
|
||||||
|
expect(within(configureModal).queryByLabelText(
|
||||||
|
configureModalMessages.timeAllotted.defaultMessage,
|
||||||
|
)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// rules box should not be visible
|
||||||
|
expect(within(configureModal).queryByLabelText(
|
||||||
|
configureModalMessages.reviewRulesLabel.defaultMessage,
|
||||||
|
)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||||
|
await act(async () => fireEvent.click(saveButton));
|
||||||
|
|
||||||
|
// verify request
|
||||||
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
|
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
|
||||||
|
|
||||||
|
// reopen modal and check values
|
||||||
|
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||||
|
await act(async () => fireEvent.click(configureBtn));
|
||||||
|
|
||||||
|
configureModal = await findByTestId('configure-modal');
|
||||||
|
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||||
|
fireEvent.click(advancedTab);
|
||||||
|
radioButtons = await within(configureModal).findAllByRole('radio');
|
||||||
|
expect(radioButtons[0]).toHaveProperty('checked', true);
|
||||||
|
expect(radioButtons[1]).toHaveProperty('checked', false);
|
||||||
|
expect(radioButtons[2]).toHaveProperty('checked', false);
|
||||||
|
expect(radioButtons[3]).toHaveProperty('checked', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check configure modal for unit', async () => {
|
||||||
|
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
|
||||||
|
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||||
|
const [subsection] = section.childInfo.children;
|
||||||
|
const [unit] = subsection.childInfo.children;
|
||||||
|
// Enrollment Track Groups : Audit
|
||||||
|
const newGroupAccess = { 50: [1] };
|
||||||
|
const isVisibleToStaffOnly = true;
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPost(getCourseItemApiUrl(unit.id), {
|
||||||
|
publish: 'republish',
|
||||||
|
metadata: {
|
||||||
|
visible_to_staff_only: isVisibleToStaffOnly,
|
||||||
|
group_access: newGroupAccess,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onGet(getXBlockApiUrl(section.id))
|
||||||
|
.reply(200, section);
|
||||||
|
|
||||||
|
const [firstSection] = await findAllByTestId('section-card');
|
||||||
|
const [firstSubsection] = await within(firstSection).findAllByTestId('subsection-card');
|
||||||
|
const subsectionExpandButton = await within(firstSubsection).getByTestId('subsection-card-header__expanded-btn');
|
||||||
|
fireEvent.click(subsectionExpandButton);
|
||||||
|
const [firstUnit] = await within(firstSubsection).findAllByTestId('unit-card');
|
||||||
|
const unitDropdownButton = await within(firstUnit).findByTestId('unit-card-header__menu-button');
|
||||||
|
|
||||||
|
// after configuraiton response
|
||||||
|
unit.visibilityState = 'staff_only';
|
||||||
|
unit.userPartitionInfo = {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: true,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: 0,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
};
|
||||||
|
subsection.childInfo.children[0] = unit;
|
||||||
|
section.childInfo.children[0] = subsection;
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onGet(getXBlockApiUrl(section.id))
|
||||||
|
.reply(200, section);
|
||||||
|
|
||||||
|
fireEvent.click(unitDropdownButton);
|
||||||
|
const configureBtn = await within(firstUnit).getByTestId('unit-card-header__menu-configure-button');
|
||||||
|
fireEvent.click(configureBtn);
|
||||||
|
|
||||||
|
let configureModal = await findByTestId('configure-modal');
|
||||||
|
expect(await within(configureModal).findByText(
|
||||||
|
configureModalMessages.unitVisibility.defaultMessage,
|
||||||
|
)).toBeInTheDocument();
|
||||||
|
let visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
|
||||||
|
await act(async () => fireEvent.click(visibilityCheckbox));
|
||||||
|
|
||||||
|
let groupeType = await within(configureModal).findByTestId('group-type-select');
|
||||||
|
fireEvent.change(groupeType, { target: { value: '0' } });
|
||||||
|
|
||||||
|
let checkboxes = await within(await within(configureModal).findByTestId('group-checkboxes')).findAllByRole('checkbox');
|
||||||
|
fireEvent.click(checkboxes[1]);
|
||||||
|
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||||
|
await act(async () => fireEvent.click(saveButton));
|
||||||
|
|
||||||
|
// reopen modal and check values
|
||||||
|
await act(async () => fireEvent.click(unitDropdownButton));
|
||||||
|
await act(async () => fireEvent.click(configureBtn));
|
||||||
|
|
||||||
|
configureModal = await findByTestId('configure-modal');
|
||||||
|
visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
|
||||||
|
expect(visibilityCheckbox).toBeChecked();
|
||||||
|
|
||||||
|
groupeType = await within(configureModal).findByTestId('group-type-select');
|
||||||
|
expect(groupeType).toHaveValue('0');
|
||||||
|
|
||||||
|
checkboxes = await within(await within(configureModal).findByTestId('group-checkboxes')).findAllByRole('checkbox');
|
||||||
|
|
||||||
|
expect(checkboxes[0]).not.toBeChecked();
|
||||||
|
expect(checkboxes[1]).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check update highlights when update highlights query is successfully', async () => {
|
||||||
|
const { getByRole } = render(<RootWrapper />);
|
||||||
|
|
||||||
|
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||||
|
const highlights = [
|
||||||
|
'New Highlight 1',
|
||||||
|
'New Highlight 2',
|
||||||
|
'New Highlight 3',
|
||||||
|
'New Highlight 4',
|
||||||
|
'New Highlight 5',
|
||||||
|
];
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPost(getCourseItemApiUrl(section.id), {
|
||||||
|
publish: 'republish',
|
||||||
|
metadata: {
|
||||||
|
highlights,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onGet(getXBlockApiUrl(section.id))
|
||||||
|
.reply(200, {
|
||||||
|
...section,
|
||||||
|
highlights,
|
||||||
|
});
|
||||||
|
|
||||||
|
await executeThunk(updateCourseSectionHighlightsQuery(section.id, highlights), store.dispatch);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByRole('button', { name: '5 Section highlights' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check whether section move up and down options work correctly', async () => {
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
// get second section element
|
||||||
|
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||||
|
const [, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||||
|
const [, sectionElement] = await findAllByTestId('section-card');
|
||||||
|
|
||||||
|
// mock api call
|
||||||
|
axiosMock
|
||||||
|
.onPut(getCourseBlockApiUrl(courseBlockId))
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
|
||||||
|
// find menu button and click on it to open menu
|
||||||
|
const menu = await within(sectionElement).findByTestId('section-card-header__menu-button');
|
||||||
|
fireEvent.click(menu);
|
||||||
|
|
||||||
|
// move second section to first position to test move up option
|
||||||
|
const moveUpButton = await within(sectionElement).findByTestId('section-card-header__menu-move-up-button');
|
||||||
|
await act(async () => fireEvent.click(moveUpButton));
|
||||||
|
const firstSectionId = store.getState().courseOutline.sectionsList[0].id;
|
||||||
|
expect(secondSection.id).toBe(firstSectionId);
|
||||||
|
|
||||||
|
// move first section back to second position to test move down option
|
||||||
|
const moveDownButton = await within(sectionElement).findByTestId('section-card-header__menu-move-down-button');
|
||||||
|
await act(async () => fireEvent.click(moveDownButton));
|
||||||
|
const newSecondSectionId = store.getState().courseOutline.sectionsList[1].id;
|
||||||
|
expect(secondSection.id).toBe(newSecondSectionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check whether section move up & down option is rendered correctly based on index', async () => {
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
// get first, second and last section element
|
||||||
|
const {
|
||||||
|
0: firstSection, 1: secondSection, length, [length - 1]: lastSection,
|
||||||
|
} = await findAllByTestId('section-card');
|
||||||
|
|
||||||
|
// find menu button and click on it to open menu in first section
|
||||||
|
const firstMenu = await within(firstSection).findByTestId('section-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(firstMenu));
|
||||||
|
// move down option should be enabled in first element
|
||||||
|
expect(
|
||||||
|
await within(firstSection).findByTestId('section-card-header__menu-move-down-button'),
|
||||||
|
).not.toHaveAttribute('aria-disabled');
|
||||||
|
// move up option should not be enabled in first element
|
||||||
|
expect(
|
||||||
|
await within(firstSection).findByTestId('section-card-header__menu-move-up-button'),
|
||||||
|
).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
|
||||||
|
// find menu button and click on it to open menu in second section
|
||||||
|
const secondMenu = await within(secondSection).findByTestId('section-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(secondMenu));
|
||||||
|
// both move down & up option should be enabled in second element
|
||||||
|
expect(
|
||||||
|
await within(secondSection).findByTestId('section-card-header__menu-move-down-button'),
|
||||||
|
).not.toHaveAttribute('aria-disabled');
|
||||||
|
expect(
|
||||||
|
await within(secondSection).findByTestId('section-card-header__menu-move-up-button'),
|
||||||
|
).not.toHaveAttribute('aria-disabled');
|
||||||
|
|
||||||
|
// find menu button and click on it to open menu in last section
|
||||||
|
const lastMenu = await within(lastSection).findByTestId('section-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(lastMenu));
|
||||||
|
// move down option should not be enabled in last element
|
||||||
|
expect(
|
||||||
|
await within(lastSection).findByTestId('section-card-header__menu-move-down-button'),
|
||||||
|
).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
// move up option should be enabled in last element
|
||||||
|
expect(
|
||||||
|
await within(lastSection).findByTestId('section-card-header__menu-move-up-button'),
|
||||||
|
).not.toHaveAttribute('aria-disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check whether subsection move up and down options work correctly', async () => {
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
// get second section element
|
||||||
|
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||||
|
const [sectionElement] = await findAllByTestId('section-card');
|
||||||
|
const [, secondSubsection] = section.childInfo.children;
|
||||||
|
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||||
|
|
||||||
|
// mock api call
|
||||||
|
axiosMock
|
||||||
|
.onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[0].id))
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
|
||||||
|
// find menu button and click on it to open menu
|
||||||
|
const menu = await within(subsectionElement).findByTestId('subsection-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(menu));
|
||||||
|
|
||||||
|
// move second subsection to first position to test move up option
|
||||||
|
const moveUpButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-move-up-button');
|
||||||
|
await act(async () => fireEvent.click(moveUpButton));
|
||||||
|
const firstSubsectionId = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id;
|
||||||
|
expect(secondSubsection.id).toBe(firstSubsectionId);
|
||||||
|
|
||||||
|
// move first section back to second position to test move down option
|
||||||
|
const moveDownButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-move-down-button');
|
||||||
|
await act(async () => fireEvent.click(moveDownButton));
|
||||||
|
const secondSubsectionId = store.getState().courseOutline.sectionsList[0].childInfo.children[1].id;
|
||||||
|
expect(secondSubsection.id).toBe(secondSubsectionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check whether subsection move up & down option is rendered correctly based on index', async () => {
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
// using second section as second section in mock has 3 subsections
|
||||||
|
const [, sectionElement] = await findAllByTestId('section-card');
|
||||||
|
// get first, second and last subsection element
|
||||||
|
const {
|
||||||
|
0: firstSubsection,
|
||||||
|
1: secondSubsection,
|
||||||
|
length,
|
||||||
|
[length - 1]: lastSubsection,
|
||||||
|
} = await within(sectionElement).findAllByTestId('subsection-card');
|
||||||
|
|
||||||
|
// find menu button and click on it to open menu in first section
|
||||||
|
const firstMenu = await within(firstSubsection).findByTestId('subsection-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(firstMenu));
|
||||||
|
// move down option should be enabled in first element
|
||||||
|
expect(
|
||||||
|
await within(firstSubsection).findByTestId('subsection-card-header__menu-move-down-button'),
|
||||||
|
).not.toHaveAttribute('aria-disabled');
|
||||||
|
// move up option should not be enabled in first element
|
||||||
|
expect(
|
||||||
|
await within(firstSubsection).findByTestId('subsection-card-header__menu-move-up-button'),
|
||||||
|
).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
|
||||||
|
// find menu button and click on it to open menu in second section
|
||||||
|
const secondMenu = await within(secondSubsection).findByTestId('subsection-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(secondMenu));
|
||||||
|
// both move down & up option should be enabled in second element
|
||||||
|
expect(
|
||||||
|
await within(secondSubsection).findByTestId('subsection-card-header__menu-move-down-button'),
|
||||||
|
).not.toHaveAttribute('aria-disabled');
|
||||||
|
expect(
|
||||||
|
await within(secondSubsection).findByTestId('subsection-card-header__menu-move-up-button'),
|
||||||
|
).not.toHaveAttribute('aria-disabled');
|
||||||
|
|
||||||
|
// find menu button and click on it to open menu in last section
|
||||||
|
const lastMenu = await within(lastSubsection).findByTestId('subsection-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(lastMenu));
|
||||||
|
// move down option should not be enabled in last element
|
||||||
|
expect(
|
||||||
|
await within(lastSubsection).findByTestId('subsection-card-header__menu-move-down-button'),
|
||||||
|
).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
// move up option should be enabled in last element
|
||||||
|
expect(
|
||||||
|
await within(lastSubsection).findByTestId('subsection-card-header__menu-move-up-button'),
|
||||||
|
).not.toHaveAttribute('aria-disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check whether unit move up and down options work correctly', async () => {
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
// get second section -> second subsection -> second unit element
|
||||||
|
const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||||
|
const [, sectionElement] = await findAllByTestId('section-card');
|
||||||
|
const [, subsection] = section.childInfo.children;
|
||||||
|
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||||
|
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||||
|
await act(async () => fireEvent.click(expandBtn));
|
||||||
|
const [, secondUnit] = subsection.childInfo.children;
|
||||||
|
const [, unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||||
|
|
||||||
|
// mock api call
|
||||||
|
axiosMock
|
||||||
|
.onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[1].childInfo.children[1].id))
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
|
||||||
|
// find menu button and click on it to open menu
|
||||||
|
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(menu));
|
||||||
|
|
||||||
|
// move second unit to first position to test move up option
|
||||||
|
const moveUpButton = await within(unitElement).findByTestId('unit-card-header__menu-move-up-button');
|
||||||
|
await act(async () => fireEvent.click(moveUpButton));
|
||||||
|
const firstUnitId = store.getState().courseOutline.sectionsList[1].childInfo.children[1].childInfo.children[0].id;
|
||||||
|
expect(secondUnit.id).toBe(firstUnitId);
|
||||||
|
|
||||||
|
// move first unit back to second position to test move down option
|
||||||
|
const moveDownButton = await within(subsectionElement).findByTestId('unit-card-header__menu-move-down-button');
|
||||||
|
await act(async () => fireEvent.click(moveDownButton));
|
||||||
|
const secondUnitId = store.getState().courseOutline.sectionsList[1].childInfo.children[1].childInfo.children[1].id;
|
||||||
|
expect(secondUnit.id).toBe(secondUnitId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check whether unit move up & down option is rendered correctly based on index', async () => {
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
// using second section -> second subsection as it has 5 units in mock.
|
||||||
|
const [, sectionElement] = await findAllByTestId('section-card');
|
||||||
|
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||||
|
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||||
|
await act(async () => fireEvent.click(expandBtn));
|
||||||
|
// get first, second and last unit element
|
||||||
|
const {
|
||||||
|
0: firstUnit,
|
||||||
|
1: secondUnit,
|
||||||
|
length,
|
||||||
|
[length - 1]: lastUnit,
|
||||||
|
} = await within(subsectionElement).findAllByTestId('unit-card');
|
||||||
|
|
||||||
|
// find menu button and click on it to open menu in first section
|
||||||
|
const firstMenu = await within(firstUnit).findByTestId('unit-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(firstMenu));
|
||||||
|
// move down option should be enabled in first element
|
||||||
|
expect(
|
||||||
|
await within(firstUnit).findByTestId('unit-card-header__menu-move-down-button'),
|
||||||
|
).not.toHaveAttribute('aria-disabled');
|
||||||
|
// move up option should not be enabled in first element
|
||||||
|
expect(
|
||||||
|
await within(firstUnit).findByTestId('unit-card-header__menu-move-up-button'),
|
||||||
|
).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
|
||||||
|
// find menu button and click on it to open menu in second section
|
||||||
|
const secondMenu = await within(secondUnit).findByTestId('unit-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(secondMenu));
|
||||||
|
// both move down & up option should be enabled in second element
|
||||||
|
expect(
|
||||||
|
await within(secondUnit).findByTestId('unit-card-header__menu-move-down-button'),
|
||||||
|
).not.toHaveAttribute('aria-disabled');
|
||||||
|
expect(
|
||||||
|
await within(secondUnit).findByTestId('unit-card-header__menu-move-up-button'),
|
||||||
|
).not.toHaveAttribute('aria-disabled');
|
||||||
|
|
||||||
|
// find menu button and click on it to open menu in last section
|
||||||
|
const lastMenu = await within(lastUnit).findByTestId('unit-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(lastMenu));
|
||||||
|
// move down option should not be enabled in last element
|
||||||
|
expect(
|
||||||
|
await within(lastUnit).findByTestId('unit-card-header__menu-move-down-button'),
|
||||||
|
).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
// move up option should be enabled in last element
|
||||||
|
expect(
|
||||||
|
await within(lastUnit).findByTestId('unit-card-header__menu-move-up-button'),
|
||||||
|
).not.toHaveAttribute('aria-disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check that new section list is saved when dragged', async () => {
|
||||||
|
const { findAllByRole } = render(<RootWrapper />);
|
||||||
|
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||||
|
const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
|
||||||
|
const draggableButton = sectionsDraggers[7];
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPut(getCourseBlockApiUrl(courseBlockId))
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
|
||||||
|
const section1 = store.getState().courseOutline.sectionsList[0].id;
|
||||||
|
|
||||||
|
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||||
|
await waitFor(async () => {
|
||||||
|
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||||
|
|
||||||
|
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||||
|
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||||
|
});
|
||||||
|
|
||||||
|
const section2 = store.getState().courseOutline.sectionsList[1].id;
|
||||||
|
expect(section1).toBe(section2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check section list is restored to original order when API call fails', async () => {
|
||||||
|
const { findAllByRole } = render(<RootWrapper />);
|
||||||
|
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||||
|
const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
|
||||||
|
const draggableButton = sectionsDraggers[6];
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPut(getCourseBlockApiUrl(courseBlockId))
|
||||||
|
.reply(500);
|
||||||
|
|
||||||
|
const section1 = store.getState().courseOutline.sectionsList[0].id;
|
||||||
|
|
||||||
|
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||||
|
await waitFor(async () => {
|
||||||
|
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||||
|
|
||||||
|
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||||
|
expect(saveStatus).toEqual(RequestStatus.FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
const section1New = store.getState().courseOutline.sectionsList[0].id;
|
||||||
|
expect(section1).toBe(section1New);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check that new subsection list is saved when dragged', async () => {
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
|
||||||
|
const [sectionElement] = await findAllByTestId('section-card');
|
||||||
|
const [section] = store.getState().courseOutline.sectionsList;
|
||||||
|
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
|
||||||
|
const draggableButton = subsectionsDraggers[1];
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPut(getCourseItemApiUrl(section.id))
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
|
||||||
|
const subsection1 = section.childInfo.children[0].id;
|
||||||
|
|
||||||
|
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||||
|
await waitFor(async () => {
|
||||||
|
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||||
|
|
||||||
|
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||||
|
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||||
|
});
|
||||||
|
|
||||||
|
const subsection2 = store.getState().courseOutline.sectionsList[0].childInfo.children[1].id;
|
||||||
|
expect(subsection1).toBe(subsection2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check that new subsection list is restored to original order when API call fails', async () => {
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
|
||||||
|
const [sectionElement] = await findAllByTestId('section-card');
|
||||||
|
const [section] = store.getState().courseOutline.sectionsList;
|
||||||
|
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
|
||||||
|
const draggableButton = subsectionsDraggers[1];
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPut(getCourseItemApiUrl(section.id))
|
||||||
|
.reply(500);
|
||||||
|
|
||||||
|
const subsection1 = section.childInfo.children[0].id;
|
||||||
|
|
||||||
|
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||||
|
await waitFor(async () => {
|
||||||
|
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||||
|
|
||||||
|
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||||
|
expect(saveStatus).toEqual(RequestStatus.FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
const subsection1New = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id;
|
||||||
|
expect(subsection1).toBe(subsection1New);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check that new unit list is saved when dragged', async () => {
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
const subsectionElement = (await findAllByTestId('subsection-card'))[3];
|
||||||
|
const [subsection] = store.getState().courseOutline.sectionsList[1].childInfo.children;
|
||||||
|
const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn');
|
||||||
|
fireEvent.click(expandBtn);
|
||||||
|
const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' });
|
||||||
|
const draggableButton = unitDraggers[1];
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPut(getCourseItemApiUrl(subsection.id))
|
||||||
|
.reply(200, { dummy: 'value' });
|
||||||
|
|
||||||
|
const unit1 = subsection.childInfo.children[0].id;
|
||||||
|
|
||||||
|
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||||
|
await waitFor(async () => {
|
||||||
|
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||||
|
|
||||||
|
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||||
|
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||||
|
});
|
||||||
|
|
||||||
|
const unit2 = store.getState().courseOutline.sectionsList[1].childInfo.children[0].childInfo.children[1].id;
|
||||||
|
expect(unit1).toBe(unit2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check that new unit list is restored to original order when API call fails', async () => {
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
const subsectionElement = (await findAllByTestId('subsection-card'))[3];
|
||||||
|
const [subsection] = store.getState().courseOutline.sectionsList[1].childInfo.children;
|
||||||
|
const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn');
|
||||||
|
fireEvent.click(expandBtn);
|
||||||
|
const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' });
|
||||||
|
const draggableButton = unitDraggers[1];
|
||||||
|
|
||||||
|
axiosMock
|
||||||
|
.onPut(getCourseItemApiUrl(subsection.id))
|
||||||
|
.reply(500);
|
||||||
|
|
||||||
|
const unit1 = subsection.childInfo.children[0].id;
|
||||||
|
|
||||||
|
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||||
|
await waitFor(async () => {
|
||||||
|
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||||
|
|
||||||
|
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||||
|
expect(saveStatus).toEqual(RequestStatus.FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
const unit1New = store.getState().courseOutline.sectionsList[1].childInfo.children[0].childInfo.children[0].id;
|
||||||
|
expect(unit1).toBe(unit1New);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check that drag handle is not visible for non-draggable sections', async () => {
|
||||||
|
axiosMock
|
||||||
|
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||||
|
.reply(200, {
|
||||||
|
...courseOutlineIndexMock,
|
||||||
|
courseStructure: {
|
||||||
|
...courseOutlineIndexMock.courseStructure,
|
||||||
|
childInfo: {
|
||||||
|
...courseOutlineIndexMock.courseStructure.childInfo,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
...courseOutlineIndexMock.courseStructure.childInfo.children[0],
|
||||||
|
actions: {
|
||||||
|
draggable: false,
|
||||||
|
childAddable: true,
|
||||||
|
deletable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...courseOutlineIndexMock.courseStructure.childInfo.children.slice(1),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||||
|
const [sectionElement] = await findAllByTestId('conditional-sortable-element--no-drag-handle');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(within(sectionElement).queryByText(section.displayName)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check whether unit copy & paste option works correctly', async () => {
|
||||||
|
const { findAllByTestId } = render(<RootWrapper />);
|
||||||
|
// get first section -> first subsection -> first unit element
|
||||||
|
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||||
|
const [sectionElement] = await findAllByTestId('section-card');
|
||||||
|
const [subsection] = section.childInfo.children;
|
||||||
|
let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||||
|
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||||
|
await act(async () => fireEvent.click(expandBtn));
|
||||||
|
const [unit] = subsection.childInfo.children;
|
||||||
|
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||||
|
|
||||||
|
const expectedClipboardContent = {
|
||||||
|
content: {
|
||||||
|
blockType: 'vertical',
|
||||||
|
blockTypeDisplay: 'Unit',
|
||||||
|
created: '2024-01-29T07:58:36.844249Z',
|
||||||
|
displayName: unit.displayName,
|
||||||
|
id: 15,
|
||||||
|
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/15/olx',
|
||||||
|
purpose: 'clipboard',
|
||||||
|
status: 'ready',
|
||||||
|
userId: 3,
|
||||||
|
},
|
||||||
|
sourceUsageKey: unit.id,
|
||||||
|
sourceContexttitle: courseOutlineIndexMock.courseStructure.displayName,
|
||||||
|
sourceEditUrl: unit.studioUrl,
|
||||||
|
};
|
||||||
|
// mock api call
|
||||||
|
axiosMock
|
||||||
|
.onPost(getClipboardUrl(), {
|
||||||
|
usage_key: unit.id,
|
||||||
|
}).reply(200, expectedClipboardContent);
|
||||||
|
// check that initialUserClipboard state is empty
|
||||||
|
const { initialUserClipboard } = store.getState().courseOutline;
|
||||||
|
expect(initialUserClipboard).toBeUndefined();
|
||||||
|
|
||||||
|
// find menu button and click on it to open menu
|
||||||
|
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(menu));
|
||||||
|
|
||||||
|
// move first unit back to second position to test move down option
|
||||||
|
const copyButton = await within(unitElement).findByText(cardHeaderMessages.menuCopy.defaultMessage);
|
||||||
|
await act(async () => fireEvent.click(copyButton));
|
||||||
|
|
||||||
|
// check that initialUserClipboard state is updated
|
||||||
|
expect(store.getState().courseOutline.initialUserClipboard).toEqual(expectedClipboardContent);
|
||||||
|
|
||||||
|
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||||
|
// find clipboard content label
|
||||||
|
const clipboardLabel = await within(subsectionElement).findByText(
|
||||||
|
pasteButtonMessages.clipboardContentLabel.defaultMessage,
|
||||||
|
);
|
||||||
|
await act(async () => fireEvent.mouseOver(clipboardLabel));
|
||||||
|
|
||||||
|
// find clipboard content popup link
|
||||||
|
expect(
|
||||||
|
subsectionElement.querySelector('#vertical-paste-button-overlay'),
|
||||||
|
).toHaveAttribute('href', unit.studioUrl);
|
||||||
|
|
||||||
|
// check paste button functionality
|
||||||
|
// mock api call
|
||||||
|
axiosMock
|
||||||
|
.onPost(getXBlockBaseApiUrl(), {
|
||||||
|
parent_locator: subsection.id,
|
||||||
|
staged_content: 'clipboard',
|
||||||
|
}).reply(200, { dummy: 'value' });
|
||||||
|
const pasteBtn = await within(subsectionElement).findByText(subsectionMessages.pasteButton.defaultMessage);
|
||||||
|
await act(async () => fireEvent.click(pasteBtn));
|
||||||
|
|
||||||
|
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||||
|
const lastUnitElement = (await within(subsectionElement).findAllByTestId('unit-card')).slice(-1)[0];
|
||||||
|
expect(lastUnitElement).toHaveTextContent(unit.displayName);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
src/course-outline/__mocks__/courseBestPractices.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
module.exports = {
|
||||||
|
isSelfPaced: false,
|
||||||
|
sections: {
|
||||||
|
totalNumber: 6,
|
||||||
|
totalVisible: 4,
|
||||||
|
numberWithHighlights: 2,
|
||||||
|
highlightsActiveForCourse: true,
|
||||||
|
highlightsEnabled: true,
|
||||||
|
},
|
||||||
|
subsections: {
|
||||||
|
totalVisible: 5,
|
||||||
|
numWithOneBlockType: 2,
|
||||||
|
numBlockTypes: {
|
||||||
|
min: 0,
|
||||||
|
max: 3,
|
||||||
|
mean: 1,
|
||||||
|
median: 1,
|
||||||
|
mode: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
units: {
|
||||||
|
totalVisible: 9,
|
||||||
|
numBlocks: {
|
||||||
|
min: 1,
|
||||||
|
max: 2,
|
||||||
|
mean: 2,
|
||||||
|
median: 2,
|
||||||
|
mode: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
videos: {
|
||||||
|
totalNumber: 7,
|
||||||
|
numMobileEncoded: 0,
|
||||||
|
numWithValId: 3,
|
||||||
|
durations: {
|
||||||
|
min: null,
|
||||||
|
max: null,
|
||||||
|
mean: null,
|
||||||
|
median: null,
|
||||||
|
mode: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
31
src/course-outline/__mocks__/courseLaunch.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
module.exports = {
|
||||||
|
isSelfPaced: false,
|
||||||
|
dates: {
|
||||||
|
hasStartDate: true,
|
||||||
|
hasEndDate: false,
|
||||||
|
},
|
||||||
|
assignments: {
|
||||||
|
totalNumber: 11,
|
||||||
|
totalVisible: 7,
|
||||||
|
assignmentsWithDatesBeforeStart: [],
|
||||||
|
assignmentsWithDatesAfterEnd: [],
|
||||||
|
assignmentsWithOraDatesBeforeStart: [],
|
||||||
|
assignmentsWithOraDatesAfterEnd: [],
|
||||||
|
},
|
||||||
|
grades: {
|
||||||
|
hasGradingPolicy: true,
|
||||||
|
sumOfWeights: 1,
|
||||||
|
},
|
||||||
|
certificates: {
|
||||||
|
isActivated: false,
|
||||||
|
hasCertificate: false,
|
||||||
|
isEnabled: true,
|
||||||
|
},
|
||||||
|
updates: {
|
||||||
|
hasUpdate: true,
|
||||||
|
},
|
||||||
|
proctoring: {
|
||||||
|
needsProctoringEscalationEmail: false,
|
||||||
|
hasProctoringEscalationEmail: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
3063
src/course-outline/__mocks__/courseOutlineIndex.js
Normal file
@@ -0,0 +1,3063 @@
|
|||||||
|
module.exports = {
|
||||||
|
courseReleaseDate: 'Set Date',
|
||||||
|
courseStructure: {
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@course+block@course',
|
||||||
|
displayName: 'Demonstration Course',
|
||||||
|
category: 'course',
|
||||||
|
hasChildren: true,
|
||||||
|
unitLevelDiscussions: false,
|
||||||
|
editedOn: 'Aug 23, 2023 at 12:35 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Aug 23, 2023 at 11:32 UTC',
|
||||||
|
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course',
|
||||||
|
releasedToStudents: false,
|
||||||
|
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
|
||||||
|
visibilityState: null,
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2023-11-09T22:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
videoSharingEnabled: true,
|
||||||
|
videoSharingOptions: 'per-video',
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
highlightsEnabledForMessaging: false,
|
||||||
|
highlightsEnabled: true,
|
||||||
|
highlightsPreviewOnly: false,
|
||||||
|
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||||
|
enableProctoredExams: true,
|
||||||
|
createZendeskTickets: true,
|
||||||
|
enableTimedExams: true,
|
||||||
|
childInfo: {
|
||||||
|
category: 'chapter',
|
||||||
|
displayName: 'Section',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b',
|
||||||
|
displayName: 'Introduction 12',
|
||||||
|
category: 'chapter',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Aug 23, 2023 at 12:35 UTC',
|
||||||
|
published: false,
|
||||||
|
publishedOn: 'Aug 23, 2023 at 12:35 UTC',
|
||||||
|
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d8a6192ade314473a78242dfeedfbf5b',
|
||||||
|
releasedToStudents: false,
|
||||||
|
releaseDate: 'Aug 10, 2023 at 22:00 UTC',
|
||||||
|
visibilityState: 'staff_only',
|
||||||
|
hasExplicitStaffLock: true,
|
||||||
|
start: '2023-08-10T22:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
highlights: [
|
||||||
|
'New Highlight 1',
|
||||||
|
'New Highlight 4',
|
||||||
|
],
|
||||||
|
highlightsEnabled: true,
|
||||||
|
highlightsPreviewOnly: false,
|
||||||
|
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||||
|
childInfo: {
|
||||||
|
category: 'sequential',
|
||||||
|
displayName: 'Subsection',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction',
|
||||||
|
displayName: 'Demo Course Overview',
|
||||||
|
category: 'sequential',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: false,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40edx_introduction',
|
||||||
|
releasedToStudents: false,
|
||||||
|
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||||
|
visibilityState: 'needs_attention',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '1970-01-01T05:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
isPrereq: false,
|
||||||
|
prereqs: [{
|
||||||
|
blockDisplayName: 'Sample Subsection',
|
||||||
|
blockUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@7f75de8dcc261249250b71925f49810f',
|
||||||
|
}],
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
hideAfterDue: false,
|
||||||
|
isProctoredExam: false,
|
||||||
|
wasExamEverLinkedWithExternal: false,
|
||||||
|
onlineProctoringRules: '',
|
||||||
|
isPracticeExam: false,
|
||||||
|
isOnboardingExam: false,
|
||||||
|
isTimeLimited: false,
|
||||||
|
examReviewRules: '',
|
||||||
|
defaultTimeLimitMinutes: null,
|
||||||
|
proctoringExamConfigurationLink: null,
|
||||||
|
supportsOnboarding: false,
|
||||||
|
showReviewRules: true,
|
||||||
|
childInfo: {
|
||||||
|
category: 'vertical',
|
||||||
|
displayName: 'Unit',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
||||||
|
displayName: 'Introduction: Video and Sequences',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: false,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
||||||
|
releasedToStudents: false,
|
||||||
|
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||||
|
visibilityState: 'needs_attention',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '1970-01-01T05:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: true,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
enableCopyPasteUnits: true,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ancestorHasStaffLock: true,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
enableCopyPasteUnits: true,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@7f75de8dcc261249250b71925f49810f',
|
||||||
|
display_name: 'Sample Subsection',
|
||||||
|
category: 'sequential',
|
||||||
|
has_children: true,
|
||||||
|
edited_on: 'Dec 05, 2023 at 10:35 UTC',
|
||||||
|
published: true,
|
||||||
|
published_on: 'Dec 05, 2023 at 10:35 UTC',
|
||||||
|
studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%407f75de8dcc261249250b71925f49810f',
|
||||||
|
released_to_students: true,
|
||||||
|
release_date: 'Feb 05, 2013 at 05:00 UTC',
|
||||||
|
visibility_state: 'live',
|
||||||
|
has_explicit_staff_lock: false,
|
||||||
|
start: '2013-02-05T05:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
due_date: '',
|
||||||
|
due: null,
|
||||||
|
relative_weeks_due: null,
|
||||||
|
format: null,
|
||||||
|
course_graders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
has_changes: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatory_message: null,
|
||||||
|
group_access: {},
|
||||||
|
user_partitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
show_correctness: 'always',
|
||||||
|
hide_after_due: false,
|
||||||
|
is_proctored_exam: false,
|
||||||
|
was_exam_ever_linked_with_external: false,
|
||||||
|
online_proctoring_rules: '',
|
||||||
|
is_practice_exam: false,
|
||||||
|
is_onboarding_exam: false,
|
||||||
|
is_time_limited: false,
|
||||||
|
isPrereq: true,
|
||||||
|
exam_review_rules: '',
|
||||||
|
default_time_limit_minutes: null,
|
||||||
|
proctoring_exam_configuration_link: null,
|
||||||
|
supports_onboarding: true,
|
||||||
|
show_review_rules: true,
|
||||||
|
child_info: {
|
||||||
|
category: 'vertical',
|
||||||
|
display_name: 'Unit',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
ancestor_has_staff_lock: false,
|
||||||
|
staff_only_message: false,
|
||||||
|
enable_copy_paste_units: true,
|
||||||
|
has_partition_group_components: false,
|
||||||
|
user_partition_info: {
|
||||||
|
selectable_partitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selected_partition_index: -1,
|
||||||
|
selected_groups_label: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: true,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions',
|
||||||
|
displayName: 'Example Week 2: Get Interactive',
|
||||||
|
category: 'chapter',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Aug 16, 2023 at 11:52 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Aug 16, 2023 at 11:52 UTC',
|
||||||
|
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40graded_interactions',
|
||||||
|
releasedToStudents: false,
|
||||||
|
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
|
||||||
|
visibilityState: 'ready',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2023-11-09T22:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
highlights: [
|
||||||
|
'New',
|
||||||
|
],
|
||||||
|
highlightsEnabled: true,
|
||||||
|
highlightsPreviewOnly: false,
|
||||||
|
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||||
|
childInfo: {
|
||||||
|
category: 'sequential',
|
||||||
|
displayName: 'Subsection',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations',
|
||||||
|
displayName: "Lesson 2 - Let's Get Interactive!",
|
||||||
|
category: 'sequential',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40simulations',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '1970-01-01T05:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
hideAfterDue: false,
|
||||||
|
isProctoredExam: true,
|
||||||
|
wasExamEverLinkedWithExternal: false,
|
||||||
|
onlineProctoringRules: '',
|
||||||
|
isPracticeExam: false,
|
||||||
|
isOnboardingExam: false,
|
||||||
|
isTimeLimited: true,
|
||||||
|
examReviewRules: '',
|
||||||
|
defaultTimeLimitMinutes: null,
|
||||||
|
proctoringExamConfigurationLink: null,
|
||||||
|
supportsOnboarding: false,
|
||||||
|
showReviewRules: true,
|
||||||
|
childInfo: {
|
||||||
|
category: 'vertical',
|
||||||
|
displayName: 'Unit',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d0d804e8863c4a95a659c04d8a2b2bc0',
|
||||||
|
displayName: "Lesson 2 - Let's Get Interactive! ",
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d0d804e8863c4a95a659c04d8a2b2bc0',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '1970-01-01T05:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_2dbb0072785e',
|
||||||
|
displayName: 'An Interactive Reference Table',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_2dbb0072785e',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '1970-01-01T05:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_98cf62510471',
|
||||||
|
displayName: 'Zooming Diagrams',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_98cf62510471',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '1970-01-01T05:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_d32bf9b2242c',
|
||||||
|
displayName: 'Electronic Sound Experiment',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_d32bf9b2242c',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '1970-01-01T05:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4e592689563243c484af947465eaef0d',
|
||||||
|
displayName: 'New Unit',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@4e592689563243c484af947465eaef0d',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '1970-01-01T05:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations',
|
||||||
|
displayName: 'Homework - Labs and Demos',
|
||||||
|
category: 'sequential',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40graded_simulations',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: 'Homework',
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
hideAfterDue: false,
|
||||||
|
isProctoredExam: false,
|
||||||
|
wasExamEverLinkedWithExternal: false,
|
||||||
|
onlineProctoringRules: '',
|
||||||
|
isPracticeExam: false,
|
||||||
|
isOnboardingExam: false,
|
||||||
|
isTimeLimited: false,
|
||||||
|
examReviewRules: '',
|
||||||
|
defaultTimeLimitMinutes: null,
|
||||||
|
proctoringExamConfigurationLink: null,
|
||||||
|
supportsOnboarding: false,
|
||||||
|
showReviewRules: true,
|
||||||
|
childInfo: {
|
||||||
|
category: 'vertical',
|
||||||
|
displayName: 'Unit',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6cee45205a449369d7ef8f159b22bdf',
|
||||||
|
displayName: 'Labs and Demos',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6cee45205a449369d7ef8f159b22bdf',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_aae927868e55',
|
||||||
|
displayName: 'Code Grader',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_aae927868e55',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_c037f3757df1',
|
||||||
|
displayName: 'Electric Circuit Simulator',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_c037f3757df1',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_bc69a47c6fae',
|
||||||
|
displayName: 'Protein Creator',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_bc69a47c6fae',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@8f89194410954e768bde1764985454a7',
|
||||||
|
displayName: 'Molecule Structures',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@8f89194410954e768bde1764985454a7',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e',
|
||||||
|
displayName: 'Homework - Essays',
|
||||||
|
category: 'sequential',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40175e76c4951144a29d46211361266e0e',
|
||||||
|
releasedToStudents: false,
|
||||||
|
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
|
||||||
|
visibilityState: 'ready',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2023-11-09T22:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
hideAfterDue: false,
|
||||||
|
isProctoredExam: false,
|
||||||
|
wasExamEverLinkedWithExternal: false,
|
||||||
|
onlineProctoringRules: '',
|
||||||
|
isPracticeExam: false,
|
||||||
|
isOnboardingExam: false,
|
||||||
|
isTimeLimited: false,
|
||||||
|
examReviewRules: '',
|
||||||
|
defaultTimeLimitMinutes: null,
|
||||||
|
proctoringExamConfigurationLink: null,
|
||||||
|
supportsOnboarding: false,
|
||||||
|
showReviewRules: true,
|
||||||
|
childInfo: {
|
||||||
|
category: 'vertical',
|
||||||
|
displayName: 'Unit',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@fb79dcbad35b466a8c6364f8ffee9050',
|
||||||
|
displayName: 'Peer Assessed Essays',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@fb79dcbad35b466a8c6364f8ffee9050',
|
||||||
|
releasedToStudents: false,
|
||||||
|
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
|
||||||
|
visibilityState: 'ready',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2023-11-09T22:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@1414ffd5143b4b508f739b563ab468b7',
|
||||||
|
displayName: 'About Exams and Certificates',
|
||||||
|
category: 'chapter',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Aug 10, 2023 at 10:40 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Aug 10, 2023 at 10:40 UTC',
|
||||||
|
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%401414ffd5143b4b508f739b563ab468b7',
|
||||||
|
releasedToStudents: false,
|
||||||
|
releaseDate: 'Jan 01, 2030 at 05:00 UTC',
|
||||||
|
visibilityState: 'needs_attention',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2030-01-01T05:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
highlights: [],
|
||||||
|
highlightsEnabled: true,
|
||||||
|
highlightsPreviewOnly: false,
|
||||||
|
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||||
|
childInfo: {
|
||||||
|
category: 'sequential',
|
||||||
|
displayName: 'Subsection',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow',
|
||||||
|
displayName: 'edX Exams',
|
||||||
|
category: 'sequential',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40workflow',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: 'Exam',
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
hideAfterDue: false,
|
||||||
|
isProctoredExam: false,
|
||||||
|
wasExamEverLinkedWithExternal: false,
|
||||||
|
onlineProctoringRules: '',
|
||||||
|
isPracticeExam: false,
|
||||||
|
isOnboardingExam: false,
|
||||||
|
isTimeLimited: false,
|
||||||
|
examReviewRules: '',
|
||||||
|
defaultTimeLimitMinutes: null,
|
||||||
|
proctoringExamConfigurationLink: null,
|
||||||
|
supportsOnboarding: false,
|
||||||
|
showReviewRules: true,
|
||||||
|
childInfo: {
|
||||||
|
category: 'vertical',
|
||||||
|
displayName: 'Unit',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@934cc32c177d41b580c8413e561346b3',
|
||||||
|
displayName: 'EdX Exams',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@934cc32c177d41b580c8413e561346b3',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_f04afeac0131',
|
||||||
|
displayName: 'Immediate Feedback',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_f04afeac0131',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@b6662b497c094bcc9b870d8270c90c93',
|
||||||
|
displayName: 'Getting Answers',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@b6662b497c094bcc9b870d8270c90c93',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@f91d8d31f7cf48ce990f8d8745ae4cfa',
|
||||||
|
displayName: 'Answering More Than Once',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@f91d8d31f7cf48ce990f8d8745ae4cfa',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_ac391cde8a91',
|
||||||
|
displayName: 'Limited Checks',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_ac391cde8a91',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_36e0beb03f0a',
|
||||||
|
displayName: 'Randomized Questions',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_36e0beb03f0a',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@1b0e2c2c84884b95b1c99fb678cc964c',
|
||||||
|
displayName: 'Overall Grade Performance',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@1b0e2c2c84884b95b1c99fb678cc964c',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff',
|
||||||
|
displayName: 'Passing a Course',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6eaa391d2be41dea20b8b1bfbcb1c45',
|
||||||
|
displayName: 'Getting Your edX Certificate',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6eaa391d2be41dea20b8b1bfbcb1c45',
|
||||||
|
releasedToStudents: true,
|
||||||
|
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||||
|
visibilityState: 'live',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2013-02-05T00:00:00Z',
|
||||||
|
graded: true,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@46e11a7b395f45b9837df6c6ac609004',
|
||||||
|
displayName: 'Publish section',
|
||||||
|
category: 'chapter',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Aug 23, 2023 at 12:22 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Aug 23, 2023 at 12:22 UTC',
|
||||||
|
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%4046e11a7b395f45b9837df6c6ac609004',
|
||||||
|
releasedToStudents: false,
|
||||||
|
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
|
||||||
|
visibilityState: 'ready',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2023-11-09T22:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
highlights: [],
|
||||||
|
highlightsEnabled: true,
|
||||||
|
highlightsPreviewOnly: false,
|
||||||
|
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||||
|
childInfo: {
|
||||||
|
category: 'sequential',
|
||||||
|
displayName: 'Subsection',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@1945e9656cbe4abe8f2020c67e9e1f61',
|
||||||
|
displayName: 'Subsection sub',
|
||||||
|
category: 'sequential',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Aug 23, 2023 at 11:32 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Aug 23, 2023 at 11:33 UTC',
|
||||||
|
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%401945e9656cbe4abe8f2020c67e9e1f61',
|
||||||
|
releasedToStudents: false,
|
||||||
|
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
|
||||||
|
visibilityState: 'ready',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2023-11-09T22:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
hideAfterDue: false,
|
||||||
|
isProctoredExam: false,
|
||||||
|
wasExamEverLinkedWithExternal: false,
|
||||||
|
onlineProctoringRules: '',
|
||||||
|
isPracticeExam: false,
|
||||||
|
isOnboardingExam: false,
|
||||||
|
isTimeLimited: false,
|
||||||
|
examReviewRules: '',
|
||||||
|
defaultTimeLimitMinutes: null,
|
||||||
|
proctoringExamConfigurationLink: null,
|
||||||
|
supportsOnboarding: false,
|
||||||
|
showReviewRules: true,
|
||||||
|
childInfo: {
|
||||||
|
category: 'vertical',
|
||||||
|
displayName: 'Unit',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@b8149aa5af944aed8eebf9c7dc9f3d0b',
|
||||||
|
displayName: 'Unit',
|
||||||
|
category: 'vertical',
|
||||||
|
hasChildren: true,
|
||||||
|
editedOn: 'Aug 23, 2023 at 11:32 UTC',
|
||||||
|
published: true,
|
||||||
|
publishedOn: 'Aug 23, 2023 at 11:33 UTC',
|
||||||
|
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@b8149aa5af944aed8eebf9c7dc9f3d0b',
|
||||||
|
releasedToStudents: false,
|
||||||
|
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
|
||||||
|
visibilityState: 'ready',
|
||||||
|
hasExplicitStaffLock: false,
|
||||||
|
start: '2023-11-09T22:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
dueDate: '',
|
||||||
|
due: null,
|
||||||
|
relativeWeeksDue: null,
|
||||||
|
format: null,
|
||||||
|
courseGraders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
hasChanges: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatoryMessage: null,
|
||||||
|
groupAccess: {},
|
||||||
|
userPartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCorrectness: 'always',
|
||||||
|
discussionEnabled: true,
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ancestorHasStaffLock: false,
|
||||||
|
staffOnlyMessage: false,
|
||||||
|
hasPartitionGroupComponents: false,
|
||||||
|
userPartitionInfo: {
|
||||||
|
selectablePartitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedPartitionIndex: -1,
|
||||||
|
selectedGroupsLabel: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deprecatedBlocksInfo: {
|
||||||
|
deprecatedEnabledBlockTypes: [],
|
||||||
|
blocks: [],
|
||||||
|
advanceSettingsUrl: '/settings/advanced/course-v1:edx+101+y76',
|
||||||
|
},
|
||||||
|
discussionsIncontextFeedbackUrl: '',
|
||||||
|
discussionsIncontextLearnmoreUrl: '',
|
||||||
|
initialState: {
|
||||||
|
expandedLocators: [
|
||||||
|
'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6',
|
||||||
|
'block-v1:edx+101+y76+type@sequential+block@8a85e287e30a47e98d8c1f37f74a6a9d',
|
||||||
|
],
|
||||||
|
locatorToShow: 'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6',
|
||||||
|
},
|
||||||
|
languageCode: 'en',
|
||||||
|
lmsLink: '//localhost:18000/courses/course-v1:edx+101+y76/jump_to/block-v1:edx+101+y76+type@course+block@course',
|
||||||
|
mfeProctoredExamSettingsUrl: '',
|
||||||
|
notificationDismissUrl: '',
|
||||||
|
proctoringErrors: [],
|
||||||
|
reindexLink: '/course/course-v1:edx+101+y76/search_reindex',
|
||||||
|
rerunNotificationId: 2,
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
module.exports = {
|
||||||
|
courseReleaseDate: 'Set Date',
|
||||||
|
courseStructure: {},
|
||||||
|
deprecatedBlocksInfo: {
|
||||||
|
deprecatedEnabledBlockTypes: [],
|
||||||
|
blocks: [],
|
||||||
|
advanceSettingsUrl: '/settings/advanced/course-v1:edx+101+y76',
|
||||||
|
},
|
||||||
|
discussionsIncontextFeedbackUrl: '',
|
||||||
|
discussionsIncontextLearnmoreUrl: '',
|
||||||
|
initialState: {
|
||||||
|
expandedLocators: [
|
||||||
|
'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6',
|
||||||
|
'block-v1:edx+101+y76+type@sequential+block@8a85e287e30a47e98d8c1f37f74a6a9d',
|
||||||
|
],
|
||||||
|
locatorToShow: 'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6',
|
||||||
|
},
|
||||||
|
languageCode: 'en',
|
||||||
|
lmsLink: '//localhost:18000/courses/course-v1:edx+101+y76/jump_to/block-v1:edx+101+y76+type@course+block@course',
|
||||||
|
mfeProctoredExamSettingsUrl: '',
|
||||||
|
notificationDismissUrl: '/course_notifications/course-v1:edx+101+y76/2',
|
||||||
|
proctoringErrors: [],
|
||||||
|
reindexLink: '/course/course-v1:edx+101+y76/search_reindex',
|
||||||
|
rerunNotificationId: 2,
|
||||||
|
};
|
||||||
93
src/course-outline/__mocks__/courseSection.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d0e78d363a424da6be5c22704c34f7a7',
|
||||||
|
display_name: 'Section',
|
||||||
|
category: 'chapter',
|
||||||
|
has_children: true,
|
||||||
|
edited_on: 'Nov 22, 2023 at 07:45 UTC',
|
||||||
|
published: true,
|
||||||
|
published_on: 'Nov 22, 2023 at 07:45 UTC',
|
||||||
|
studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d0e78d363a424da6be5c22704c34f7a7',
|
||||||
|
released_to_students: true,
|
||||||
|
release_date: 'Feb 05, 2013 at 05:00 UTC',
|
||||||
|
visibility_state: 'live',
|
||||||
|
has_explicit_staff_lock: false,
|
||||||
|
start: '2013-02-05T05:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
due_date: '',
|
||||||
|
due: null,
|
||||||
|
relative_weeks_due: null,
|
||||||
|
format: null,
|
||||||
|
course_graders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
has_changes: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatory_message: null,
|
||||||
|
group_access: {},
|
||||||
|
user_partitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
show_correctness: 'always',
|
||||||
|
highlights: [],
|
||||||
|
highlights_enabled: true,
|
||||||
|
highlights_preview_only: false,
|
||||||
|
highlights_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||||
|
child_info: {
|
||||||
|
category: 'sequential',
|
||||||
|
display_name: 'Subsection',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
ancestor_has_staff_lock: false,
|
||||||
|
staff_only_message: false,
|
||||||
|
enable_copy_paste_units: false,
|
||||||
|
has_partition_group_components: false,
|
||||||
|
user_partition_info: {
|
||||||
|
selectable_partitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selected_partition_index: -1,
|
||||||
|
selected_groups_label: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
101
src/course-outline/__mocks__/courseSubsection.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@b713bc2830f34f6f87554028c3068729',
|
||||||
|
display_name: 'Subsection',
|
||||||
|
category: 'sequential',
|
||||||
|
has_children: true,
|
||||||
|
edited_on: 'Dec 05, 2023 at 10:35 UTC',
|
||||||
|
published: true,
|
||||||
|
published_on: 'Dec 05, 2023 at 10:35 UTC',
|
||||||
|
studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40b713bc2830f34f6f87554028c3068729',
|
||||||
|
released_to_students: true,
|
||||||
|
release_date: 'Feb 05, 2013 at 05:00 UTC',
|
||||||
|
visibility_state: 'live',
|
||||||
|
has_explicit_staff_lock: false,
|
||||||
|
start: '2013-02-05T05:00:00Z',
|
||||||
|
graded: false,
|
||||||
|
due_date: '',
|
||||||
|
due: null,
|
||||||
|
relative_weeks_due: null,
|
||||||
|
format: null,
|
||||||
|
course_graders: [
|
||||||
|
'Homework',
|
||||||
|
'Exam',
|
||||||
|
],
|
||||||
|
has_changes: false,
|
||||||
|
actions: {
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
explanatory_message: null,
|
||||||
|
group_access: {},
|
||||||
|
user_partitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
show_correctness: 'always',
|
||||||
|
hide_after_due: false,
|
||||||
|
is_proctored_exam: false,
|
||||||
|
was_exam_ever_linked_with_external: false,
|
||||||
|
online_proctoring_rules: '',
|
||||||
|
is_practice_exam: false,
|
||||||
|
is_onboarding_exam: false,
|
||||||
|
is_time_limited: false,
|
||||||
|
exam_review_rules: '',
|
||||||
|
default_time_limit_minutes: null,
|
||||||
|
proctoring_exam_configuration_link: null,
|
||||||
|
supports_onboarding: false,
|
||||||
|
show_review_rules: true,
|
||||||
|
child_info: {
|
||||||
|
category: 'vertical',
|
||||||
|
display_name: 'Unit',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
ancestor_has_staff_lock: false,
|
||||||
|
staff_only_message: false,
|
||||||
|
enable_copy_paste_units: false,
|
||||||
|
has_partition_group_components: false,
|
||||||
|
user_partition_info: {
|
||||||
|
selectable_partitions: [
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: 'Enrollment Track Groups',
|
||||||
|
scheme: 'enrollment_track',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Verified Certificate',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Audit',
|
||||||
|
selected: false,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selected_partition_index: -1,
|
||||||
|
selected_groups_label: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
6
src/course-outline/__mocks__/index.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { default as courseOutlineIndexMock } from './courseOutlineIndex';
|
||||||
|
export { default as courseOutlineIndexWithoutSections } from './courseOutlineIndexWithoutSections';
|
||||||
|
export { default as courseBestPracticesMock } from './courseBestPractices';
|
||||||
|
export { default as courseLaunchMock } from './courseLaunch';
|
||||||
|
export { default as courseSectionMock } from './courseSection';
|
||||||
|
export { default as courseSubsectionMock } from './courseSubsection';
|
||||||
266
src/course-outline/card-header/CardHeader.jsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Dropdown,
|
||||||
|
Form,
|
||||||
|
Hyperlink,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
} from '@edx/paragon';
|
||||||
|
import {
|
||||||
|
MoreVert as MoveVertIcon,
|
||||||
|
EditOutline as EditIcon,
|
||||||
|
} from '@edx/paragon/icons';
|
||||||
|
|
||||||
|
import { useEscapeClick } from '../../hooks';
|
||||||
|
import { ITEM_BADGE_STATUS } from '../constants';
|
||||||
|
import { scrollToElement } from '../utils';
|
||||||
|
import CardStatus from './CardStatus';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
const CardHeader = ({
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
cardId,
|
||||||
|
hasChanges,
|
||||||
|
onClickPublish,
|
||||||
|
onClickConfigure,
|
||||||
|
onClickMenuButton,
|
||||||
|
onClickEdit,
|
||||||
|
isFormOpen,
|
||||||
|
onEditSubmit,
|
||||||
|
closeForm,
|
||||||
|
isDisabledEditField,
|
||||||
|
onClickDelete,
|
||||||
|
onClickDuplicate,
|
||||||
|
onClickMoveUp,
|
||||||
|
onClickMoveDown,
|
||||||
|
onClickCopy,
|
||||||
|
titleComponent,
|
||||||
|
namePrefix,
|
||||||
|
actions,
|
||||||
|
enableCopyPasteUnits,
|
||||||
|
isVertical,
|
||||||
|
isSequential,
|
||||||
|
proctoringExamConfigurationLink,
|
||||||
|
discussionEnabled,
|
||||||
|
discussionsSettings,
|
||||||
|
parentInfo,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [titleValue, setTitleValue] = useState(title);
|
||||||
|
const cardHeaderRef = useRef(null);
|
||||||
|
|
||||||
|
const isDisabledPublish = (status === ITEM_BADGE_STATUS.live
|
||||||
|
|| status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const locatorId = searchParams.get('show');
|
||||||
|
if (!locatorId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardHeaderRef.current && locatorId === cardId) {
|
||||||
|
scrollToElement(cardHeaderRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showDiscussionsEnabledBadge = (
|
||||||
|
isVertical
|
||||||
|
&& !parentInfo?.isTimeLimited
|
||||||
|
&& discussionEnabled
|
||||||
|
&& discussionsSettings?.providerType === 'openedx'
|
||||||
|
&& (
|
||||||
|
discussionsSettings?.enableGradedUnits
|
||||||
|
|| (!discussionsSettings?.enableGradedUnits && !parentInfo.graded)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEscapeClick({
|
||||||
|
onEscape: () => {
|
||||||
|
setTitleValue(title);
|
||||||
|
closeForm();
|
||||||
|
},
|
||||||
|
dependency: title,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="item-card-header"
|
||||||
|
data-testid={`${namePrefix}-card-header`}
|
||||||
|
ref={cardHeaderRef}
|
||||||
|
>
|
||||||
|
{isFormOpen ? (
|
||||||
|
<Form.Group className="m-0 w-75">
|
||||||
|
<Form.Control
|
||||||
|
data-testid={`${namePrefix}-edit-field`}
|
||||||
|
ref={(e) => e && e.focus()}
|
||||||
|
value={titleValue}
|
||||||
|
name="displayName"
|
||||||
|
onChange={(e) => setTitleValue(e.target.value)}
|
||||||
|
aria-label="edit field"
|
||||||
|
onBlur={() => onEditSubmit(titleValue)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
onEditSubmit(titleValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isDisabledEditField}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{titleComponent}
|
||||||
|
<IconButton
|
||||||
|
className="item-card-edit-icon"
|
||||||
|
data-testid={`${namePrefix}-edit-button`}
|
||||||
|
alt={intl.formatMessage(messages.altButtonEdit)}
|
||||||
|
iconAs={EditIcon}
|
||||||
|
onClick={onClickEdit}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto d-flex">
|
||||||
|
{(isVertical || isSequential) && (
|
||||||
|
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge} />
|
||||||
|
)}
|
||||||
|
<Dropdown data-testid={`${namePrefix}-card-header__menu`} onClick={onClickMenuButton}>
|
||||||
|
<Dropdown.Toggle
|
||||||
|
className="item-card-header__menu"
|
||||||
|
id={`${namePrefix}-card-header__menu`}
|
||||||
|
data-testid={`${namePrefix}-card-header__menu-button`}
|
||||||
|
as={IconButton}
|
||||||
|
src={MoveVertIcon}
|
||||||
|
alt={`${namePrefix}-card-header__menu`}
|
||||||
|
iconAs={Icon}
|
||||||
|
/>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
{isSequential && proctoringExamConfigurationLink && (
|
||||||
|
<Dropdown.Item
|
||||||
|
as={Hyperlink}
|
||||||
|
target="_blank"
|
||||||
|
destination={proctoringExamConfigurationLink}
|
||||||
|
href={proctoringExamConfigurationLink}
|
||||||
|
externalLinkTitle={intl.formatMessage(messages.proctoringLinkTooltip)}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.menuProctoringLinkText)}
|
||||||
|
</Dropdown.Item>
|
||||||
|
)}
|
||||||
|
<Dropdown.Item
|
||||||
|
data-testid={`${namePrefix}-card-header__menu-publish-button`}
|
||||||
|
disabled={isDisabledPublish}
|
||||||
|
onClick={onClickPublish}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.menuPublish)}
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item
|
||||||
|
data-testid={`${namePrefix}-card-header__menu-configure-button`}
|
||||||
|
onClick={onClickConfigure}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.menuConfigure)}
|
||||||
|
</Dropdown.Item>
|
||||||
|
{isVertical && enableCopyPasteUnits && (
|
||||||
|
<Dropdown.Item onClick={onClickCopy}>
|
||||||
|
{intl.formatMessage(messages.menuCopy)}
|
||||||
|
</Dropdown.Item>
|
||||||
|
)}
|
||||||
|
{actions.duplicable && (
|
||||||
|
<Dropdown.Item
|
||||||
|
data-testid={`${namePrefix}-card-header__menu-duplicate-button`}
|
||||||
|
onClick={onClickDuplicate}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.menuDuplicate)}
|
||||||
|
</Dropdown.Item>
|
||||||
|
)}
|
||||||
|
{actions.draggable && (
|
||||||
|
<>
|
||||||
|
<Dropdown.Item
|
||||||
|
data-testid={`${namePrefix}-card-header__menu-move-up-button`}
|
||||||
|
onClick={onClickMoveUp}
|
||||||
|
disabled={!actions.allowMoveUp}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.menuMoveUp)}
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item
|
||||||
|
data-testid={`${namePrefix}-card-header__menu-move-down-button`}
|
||||||
|
onClick={onClickMoveDown}
|
||||||
|
disabled={!actions.allowMoveDown}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.menuMoveDown)}
|
||||||
|
</Dropdown.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{actions.deletable && (
|
||||||
|
<Dropdown.Item
|
||||||
|
className="border-top border-light"
|
||||||
|
data-testid={`${namePrefix}-card-header__menu-delete-button`}
|
||||||
|
onClick={onClickDelete}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.menuDelete)}
|
||||||
|
</Dropdown.Item>
|
||||||
|
)}
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CardHeader.defaultProps = {
|
||||||
|
enableCopyPasteUnits: false,
|
||||||
|
isVertical: false,
|
||||||
|
isSequential: false,
|
||||||
|
onClickCopy: null,
|
||||||
|
proctoringExamConfigurationLink: null,
|
||||||
|
discussionEnabled: false,
|
||||||
|
discussionsSettings: {},
|
||||||
|
parentInfo: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
CardHeader.propTypes = {
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
status: PropTypes.string.isRequired,
|
||||||
|
cardId: PropTypes.string.isRequired,
|
||||||
|
hasChanges: PropTypes.bool.isRequired,
|
||||||
|
onClickPublish: PropTypes.func.isRequired,
|
||||||
|
onClickConfigure: PropTypes.func.isRequired,
|
||||||
|
onClickMenuButton: PropTypes.func.isRequired,
|
||||||
|
onClickEdit: PropTypes.func.isRequired,
|
||||||
|
isFormOpen: PropTypes.bool.isRequired,
|
||||||
|
onEditSubmit: PropTypes.func.isRequired,
|
||||||
|
closeForm: PropTypes.func.isRequired,
|
||||||
|
isDisabledEditField: PropTypes.bool.isRequired,
|
||||||
|
onClickDelete: PropTypes.func.isRequired,
|
||||||
|
onClickDuplicate: PropTypes.func.isRequired,
|
||||||
|
onClickMoveUp: PropTypes.func.isRequired,
|
||||||
|
onClickMoveDown: PropTypes.func.isRequired,
|
||||||
|
onClickCopy: PropTypes.func,
|
||||||
|
titleComponent: PropTypes.node.isRequired,
|
||||||
|
namePrefix: PropTypes.string.isRequired,
|
||||||
|
proctoringExamConfigurationLink: PropTypes.string,
|
||||||
|
actions: PropTypes.shape({
|
||||||
|
deletable: PropTypes.bool.isRequired,
|
||||||
|
draggable: PropTypes.bool.isRequired,
|
||||||
|
childAddable: PropTypes.bool.isRequired,
|
||||||
|
duplicable: PropTypes.bool.isRequired,
|
||||||
|
allowMoveUp: PropTypes.bool,
|
||||||
|
allowMoveDown: PropTypes.bool,
|
||||||
|
}).isRequired,
|
||||||
|
enableCopyPasteUnits: PropTypes.bool,
|
||||||
|
isVertical: PropTypes.bool,
|
||||||
|
isSequential: PropTypes.bool,
|
||||||
|
discussionEnabled: PropTypes.bool,
|
||||||
|
discussionsSettings: PropTypes.shape({
|
||||||
|
providerType: PropTypes.string,
|
||||||
|
enableGradedUnits: PropTypes.bool,
|
||||||
|
}),
|
||||||
|
parentInfo: PropTypes.shape({
|
||||||
|
isTimeLimited: PropTypes.bool,
|
||||||
|
graded: PropTypes.bool,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardHeader;
|
||||||
29
src/course-outline/card-header/CardHeader.scss
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
.item-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.item-card-header__title-btn {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0;
|
||||||
|
width: fit-content;
|
||||||
|
height: 1.5rem;
|
||||||
|
margin-right: .25rem;
|
||||||
|
background: transparent;
|
||||||
|
color: $black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-edit-icon {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .3s linear;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.item-card-edit-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
254
src/course-outline/card-header/CardHeader.test.jsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
act, render, fireEvent, waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
import { ITEM_BADGE_STATUS } from '../constants';
|
||||||
|
import CardHeader from './CardHeader';
|
||||||
|
import TitleButton from './TitleButton';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
const onExpandMock = jest.fn();
|
||||||
|
const onClickMenuButtonMock = jest.fn();
|
||||||
|
const onClickPublishMock = jest.fn();
|
||||||
|
const onClickEditMock = jest.fn();
|
||||||
|
const onClickDeleteMock = jest.fn();
|
||||||
|
const onClickDuplicateMock = jest.fn();
|
||||||
|
const onClickConfigureMock = jest.fn();
|
||||||
|
const onClickMoveUpMock = jest.fn();
|
||||||
|
const onClickMoveDownMock = jest.fn();
|
||||||
|
const closeFormMock = jest.fn();
|
||||||
|
|
||||||
|
const cardHeaderProps = {
|
||||||
|
title: 'Some title',
|
||||||
|
status: ITEM_BADGE_STATUS.live,
|
||||||
|
cardId: '12345',
|
||||||
|
hasChanges: false,
|
||||||
|
onClickMenuButton: onClickMenuButtonMock,
|
||||||
|
onClickPublish: onClickPublishMock,
|
||||||
|
onClickEdit: onClickEditMock,
|
||||||
|
isFormOpen: false,
|
||||||
|
onEditSubmit: jest.fn(),
|
||||||
|
closeForm: closeFormMock,
|
||||||
|
isDisabledEditField: false,
|
||||||
|
onClickDelete: onClickDeleteMock,
|
||||||
|
onClickDuplicate: onClickDuplicateMock,
|
||||||
|
onClickConfigure: onClickConfigureMock,
|
||||||
|
onClickMoveUp: onClickMoveUpMock,
|
||||||
|
onClickMoveDown: onClickMoveDownMock,
|
||||||
|
isSequential: true,
|
||||||
|
namePrefix: 'subsection',
|
||||||
|
actions: {
|
||||||
|
draggable: true,
|
||||||
|
childAddable: true,
|
||||||
|
deletable: true,
|
||||||
|
duplicable: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderComponent = (props, entry = '/') => {
|
||||||
|
const titleComponent = (
|
||||||
|
<TitleButton
|
||||||
|
isExpanded
|
||||||
|
title={cardHeaderProps.title}
|
||||||
|
onTitleClick={onExpandMock}
|
||||||
|
namePrefix={cardHeaderProps.namePrefix}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<MemoryRouter initialEntries={[entry]}>
|
||||||
|
<CardHeader
|
||||||
|
{...cardHeaderProps}
|
||||||
|
titleComponent={titleComponent}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
</IntlProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('<CardHeader />', () => {
|
||||||
|
it('render CardHeader component correctly', async () => {
|
||||||
|
const { findByText, findByTestId, queryByTestId } = renderComponent();
|
||||||
|
|
||||||
|
expect(await findByText(cardHeaderProps.title)).toBeInTheDocument();
|
||||||
|
expect(await findByTestId('subsection-card-header__expanded-btn')).toBeInTheDocument();
|
||||||
|
expect(await findByTestId('subsection-card-header__menu')).toBeInTheDocument();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryByTestId('edit field')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('render status badge as live', async () => {
|
||||||
|
const { findByText } = renderComponent();
|
||||||
|
expect(await findByText(messages.statusBadgeLive.defaultMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('render status badge as published_not_live', async () => {
|
||||||
|
const { findByText } = renderComponent({
|
||||||
|
...cardHeaderProps,
|
||||||
|
status: ITEM_BADGE_STATUS.publishedNotLive,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await findByText(messages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('render status badge as staff_only', async () => {
|
||||||
|
const { findByText } = renderComponent({
|
||||||
|
...cardHeaderProps,
|
||||||
|
status: ITEM_BADGE_STATUS.staffOnly,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await findByText(messages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('render status badge as draft', async () => {
|
||||||
|
const { findByText } = renderComponent({
|
||||||
|
...cardHeaderProps,
|
||||||
|
status: ITEM_BADGE_STATUS.draft,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check publish menu item is disabled when subsection status is live or published not live and it has no changes', async () => {
|
||||||
|
const { findByText, findByTestId } = renderComponent({
|
||||||
|
...cardHeaderProps,
|
||||||
|
status: ITEM_BADGE_STATUS.publishedNotLive,
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||||
|
fireEvent.click(menuButton);
|
||||||
|
expect(await findByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check publish menu item is enabled when subsection status is live or published not live and it has changes', async () => {
|
||||||
|
const { findByText, findByTestId } = renderComponent({
|
||||||
|
...cardHeaderProps,
|
||||||
|
status: ITEM_BADGE_STATUS.publishedNotLive,
|
||||||
|
hasChanges: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||||
|
fireEvent.click(menuButton);
|
||||||
|
expect(await findByText(messages.menuPublish.defaultMessage)).not.toHaveAttribute('aria-disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls handleExpanded when button is clicked', async () => {
|
||||||
|
const { findByTestId } = renderComponent();
|
||||||
|
|
||||||
|
const expandButton = await findByTestId('subsection-card-header__expanded-btn');
|
||||||
|
fireEvent.click(expandButton);
|
||||||
|
expect(onExpandMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClickMenuButton when menu is clicked', async () => {
|
||||||
|
const { findByTestId } = renderComponent();
|
||||||
|
|
||||||
|
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(menuButton));
|
||||||
|
expect(onClickMenuButtonMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClickPublish when item is clicked', async () => {
|
||||||
|
const { findByText, findByTestId } = renderComponent({
|
||||||
|
...cardHeaderProps,
|
||||||
|
status: ITEM_BADGE_STATUS.draft,
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||||
|
fireEvent.click(menuButton);
|
||||||
|
|
||||||
|
const publishMenuItem = await findByText(messages.menuPublish.defaultMessage);
|
||||||
|
await act(async () => fireEvent.click(publishMenuItem));
|
||||||
|
expect(onClickPublishMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClickEdit when the button is clicked', async () => {
|
||||||
|
const { findByTestId } = renderComponent();
|
||||||
|
|
||||||
|
const editButton = await findByTestId('subsection-edit-button');
|
||||||
|
await act(async () => fireEvent.click(editButton));
|
||||||
|
expect(onClickEditMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check is field visible when isFormOpen is true', async () => {
|
||||||
|
const { findByTestId, queryByTestId } = renderComponent({
|
||||||
|
...cardHeaderProps,
|
||||||
|
isFormOpen: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await findByTestId('subsection-edit-field')).toBeInTheDocument();
|
||||||
|
waitFor(() => {
|
||||||
|
expect(queryByTestId('subsection-card-header__expanded-btn')).not.toBeInTheDocument();
|
||||||
|
expect(queryByTestId('edit-button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check is field disabled when isDisabledEditField is true', async () => {
|
||||||
|
const { findByTestId } = renderComponent({
|
||||||
|
...cardHeaderProps,
|
||||||
|
isFormOpen: true,
|
||||||
|
isDisabledEditField: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await findByTestId('subsection-edit-field')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClickDelete when item is clicked', async () => {
|
||||||
|
const { findByText, findByTestId } = renderComponent();
|
||||||
|
|
||||||
|
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(menuButton));
|
||||||
|
const deleteMenuItem = await findByText(messages.menuDelete.defaultMessage);
|
||||||
|
await act(async () => fireEvent.click(deleteMenuItem));
|
||||||
|
expect(onClickDeleteMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClickDuplicate when item is clicked', async () => {
|
||||||
|
const { findByText, findByTestId } = renderComponent();
|
||||||
|
|
||||||
|
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||||
|
fireEvent.click(menuButton);
|
||||||
|
|
||||||
|
const duplicateMenuItem = await findByText(messages.menuDuplicate.defaultMessage);
|
||||||
|
fireEvent.click(duplicateMenuItem);
|
||||||
|
await act(async () => fireEvent.click(duplicateMenuItem));
|
||||||
|
expect(onClickDuplicateMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check if proctoringExamConfigurationLink is visible', async () => {
|
||||||
|
const { findByText, findByTestId } = renderComponent({
|
||||||
|
...cardHeaderProps,
|
||||||
|
proctoringExamConfigurationLink: 'https://localhost:8000/',
|
||||||
|
isSequential: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||||
|
await act(async () => fireEvent.click(menuButton));
|
||||||
|
|
||||||
|
expect(await findByText(messages.menuProctoringLinkText.defaultMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check if discussion enabled badge is visible', async () => {
|
||||||
|
const { queryByText } = renderComponent({
|
||||||
|
...cardHeaderProps,
|
||||||
|
isVertical: true,
|
||||||
|
discussionEnabled: true,
|
||||||
|
discussionsSettings: {
|
||||||
|
providerType: 'openedx',
|
||||||
|
enableGradedUnits: true,
|
||||||
|
},
|
||||||
|
parentInfo: {
|
||||||
|
isTimeLimited: false,
|
||||||
|
graded: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
40
src/course-outline/card-header/CardStatus.jsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { ITEM_BADGE_STATUS } from '../constants';
|
||||||
|
import { getItemStatusBadgeContent } from '../utils';
|
||||||
|
import messages from './messages';
|
||||||
|
import StatusBadge from './StatusBadge';
|
||||||
|
|
||||||
|
const CardStatus = ({
|
||||||
|
status,
|
||||||
|
showDiscussionsEnabledBadge,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showDiscussionsEnabledBadge && (
|
||||||
|
<StatusBadge
|
||||||
|
text={intl.formatMessage(messages.discussionEnabledBadgeText)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{badgeTitle && (
|
||||||
|
<StatusBadge
|
||||||
|
text={badgeTitle}
|
||||||
|
icon={badgeIcon}
|
||||||
|
iconClassName={classNames({ 'text-success-500': status === ITEM_BADGE_STATUS.live })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CardStatus.propTypes = {
|
||||||
|
status: PropTypes.string.isRequired,
|
||||||
|
showDiscussionsEnabledBadge: PropTypes.bool.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardStatus;
|
||||||