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 |
12
.env
@@ -16,15 +16,27 @@ LOGO_URL=''
|
||||
LOGO_WHITE_URL=''
|
||||
LOGOUT_URL=null
|
||||
MARKETING_SITE_BASE_URL=''
|
||||
TERMS_OF_SERVICE_URL=''
|
||||
PRIVACY_POLICY_URL=''
|
||||
ORDER_HISTORY_URL=''
|
||||
PUBLISHER_BASE_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=''
|
||||
STUDIO_SHORT_NAME='Studio'
|
||||
SUPPORT_EMAIL=''
|
||||
SUPPORT_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
ENABLE_ACCESSIBILITY_PAGE=false
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_UNIT_PAGE=false
|
||||
ENABLE_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'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:2001'
|
||||
BASE_URL='http://localhost:2001'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL=
|
||||
@@ -16,17 +16,29 @@ LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
TERMS_OF_SERVICE_URL=
|
||||
PRIVACY_POLICY_URL=
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
PORT=2001
|
||||
PUBLISHER_BASE_URL=
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
SITE_NAME='Your Plaform Name Here'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
SUPPORT_EMAIL='support@example.com'
|
||||
STUDIO_SHORT_NAME='Studio'
|
||||
SUPPORT_EMAIL=
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_ACCESSIBILITY_PAGE=false
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_UNIT_PAGE=false
|
||||
ENABLE_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'
|
||||
BASE_URL='localhost:2001'
|
||||
BASE_URL='http://localhost:2001'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
@@ -22,10 +22,15 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
STUDIO_SHORT_NAME='Studio'
|
||||
SUPPORT_EMAIL='support@example.com'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_UNIT_PAGE=true
|
||||
ENABLE_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');
|
||||
|
||||
module.exports = createConfig(
|
||||
'eslint',
|
||||
'eslint',
|
||||
{
|
||||
rules: {
|
||||
'jsx-a11y/label-has-associated-control': [2, {
|
||||
@@ -10,7 +10,7 @@ module.exports = createConfig(
|
||||
}],
|
||||
'template-curly-spacing': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
indent: 'off',
|
||||
indent: ['error', 2],
|
||||
'no-restricted-exports': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
|
||||
11
.github/workflows/validate.yml
vendored
@@ -9,14 +9,13 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
|
||||
3
.gitignore
vendored
@@ -20,3 +20,6 @@ temp/babel-plugin-react-intl
|
||||
/temp
|
||||
/.vscode
|
||||
/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
|
||||
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
|
||||
i18n = ./src/i18n
|
||||
transifex_input = $(i18n)/transifex_input.json
|
||||
|
||||
# This directory must match .babelrc .
|
||||
transifex_temp = ./temp/babel-plugin-react-intl
|
||||
transifex_temp = ./temp/babel-plugin-formatjs
|
||||
|
||||
precommit:
|
||||
npm run lint
|
||||
npm audit
|
||||
|
||||
requirements:
|
||||
npm install
|
||||
npm ci
|
||||
|
||||
i18n.extract:
|
||||
# Pulling display strings from .jsx files into .json files...
|
||||
@@ -43,9 +44,26 @@ push_translations:
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
ifeq ($(OPENEDX_ATLAS_PULL),)
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -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.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
@@ -57,6 +75,7 @@ validate:
|
||||
make validate-no-uncommitted-package-lock-changes
|
||||
npm run i18n_extract
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run types
|
||||
npm run test
|
||||
npm run build
|
||||
|
||||
|
||||
274
README.rst
@@ -1,20 +1,70 @@
|
||||
|Build Status| |Codecov| |license|
|
||||
|
||||
#############################
|
||||
frontend-app-course-authoring
|
||||
#############################
|
||||
|
||||
Please tag `@edx/teaching-and-learning <https://github.com/orgs/edx/teams/teaching-and-learning>`_ on any PRs or issues. Thanks.
|
||||
|license-badge| |status-badge| |codecov-badge|
|
||||
|
||||
************
|
||||
Introduction
|
||||
************
|
||||
|
||||
Purpose
|
||||
*******
|
||||
|
||||
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
|
||||
|
||||
Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below.
|
||||
|
||||
********
|
||||
|
||||
Getting Started
|
||||
************
|
||||
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
The `devstack`_ is currently recommended as a development environment for your
|
||||
new MFE. If you start it with ``make dev.up.lms`` that should give you
|
||||
everything you need as a companion to this frontend.
|
||||
|
||||
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ to get started using it.
|
||||
|
||||
.. _Devstack: https://github.com/openedx/devstack
|
||||
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
All features that integrate into the edx-platform CMS require that the ``COURSE_AUTHORING_MICROFRONTEND_URL`` Django setting is set in the CMS environment and points to this MFE's deployment URL. This should be done automatically if you are using devstack or tutor-mfe.
|
||||
|
||||
Cloning and Startup
|
||||
===================
|
||||
|
||||
|
||||
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
|
||||
********
|
||||
|
||||
@@ -23,14 +73,12 @@ Feature: Pages and Resources Studio Tab
|
||||
|
||||
Enables a "Pages & Resources" menu item in Studio, under the "Content" menu.
|
||||
|
||||
.. image:: ./docs/readme-images/feature-pages-resources.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
The following are external 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.
|
||||
The following are requirements for this feature to function correctly:
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
@@ -79,15 +127,13 @@ For a particular course, this page allows one to:
|
||||
Feature: New React XBlock Editors
|
||||
=================================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-problem-editor.png
|
||||
|
||||
This allows an operator to enable the use of new React editors for the HTML, Video, and Problem XBlocks, all of which are provided here.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` 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:
|
||||
|
||||
* ``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:
|
||||
|
||||
* ``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
|
||||
-------------------
|
||||
@@ -113,12 +159,13 @@ When a corresponding waffle flag is set, upon editing a block in Studio, the vie
|
||||
Feature: New Proctoring Exams View
|
||||
==================================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-proctored-exams.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``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
|
||||
|
||||
* ``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
|
||||
* Enable automatic creation of Zendesk tickets for "suspicious" proctored exam attempts
|
||||
|
||||
Feature: Advanced Settings
|
||||
==========================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-advanced-settings.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_advanced_settings_page``: this feature flag must be enabled for the link to the settings view to be shown. It can be enabled on a per-course basis.
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
In Studio, the "Advanced Settings" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. The advanced settings page holds many different settings for the course, such as what features or XBlocks are enabled.
|
||||
|
||||
Feature: Files & Uploads
|
||||
==========================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-files-uploads.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_files_uploads_page``: this feature flag must be enabled for the link to the Files & Uploads page to go to the MFE. It can be enabled on a per-course basis.
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
In Studio, the "Files & Uploads" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. This page allows managing static asset files like PDFs, images, etc. used for the course.
|
||||
|
||||
Feature: Course Updates
|
||||
==========================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-course-updates.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_updates_page``: this feature flag must be enabled.
|
||||
|
||||
Feature: Import/Export Pages
|
||||
============================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-export.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_export_page``: this feature flag will change the CMS to link to the new export page.
|
||||
* ``contentstore.new_studio_mfe.use_new_import_page``: this feature flag will change the CMS to link to the new import page.
|
||||
|
||||
Feature: Tagging/Taxonomy Pages
|
||||
================================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-tagging-taxonomy-pages.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``new_studio_mfe.use_tagging_taxonomy_list_page``: this feature flag must be enabled.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
In additional to the standard settings, the following local configuration items are required:
|
||||
|
||||
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled in order to actually present the new Tagging/Taxonomy pages.
|
||||
|
||||
|
||||
**********
|
||||
Developing
|
||||
**********
|
||||
|
||||
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
|
||||
|
||||
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:
|
||||
|
||||
- `Proctored Exam Settings <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/proctored-exam-settings>`_
|
||||
- `Pages and Resources <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources>`_ (work in progress)
|
||||
- `Pages and Resources <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources>`_
|
||||
|
||||
Troubleshooting
|
||||
========================
|
||||
@@ -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.
|
||||
(https://github.com/Automattic/node-canvas/issues/1733)
|
||||
|
||||
*********
|
||||
|
||||
Deploying
|
||||
*********
|
||||
|
||||
@@ -197,3 +304,92 @@ The production build is created with ``npm run build``.
|
||||
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
|
||||
:target: @edx/frontend-app-course-authoring
|
||||
|
||||
Internationalization
|
||||
====================
|
||||
|
||||
Please see refer to the `frontend-platform i18n howto`_ for documentation on
|
||||
internationalization.
|
||||
|
||||
.. _frontend-platform i18n howto: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
|
||||
|
||||
|
||||
Getting Help
|
||||
************
|
||||
|
||||
If you're having trouble, we have discussion forums at
|
||||
https://discuss.openedx.org where you can connect with others in the community.
|
||||
|
||||
Our real-time conversations are on Slack. You can request a `Slack
|
||||
invitation`_, then join our `community Slack workspace`_. Because this is a
|
||||
frontend repository, the best place to discuss it would be in the `#wg-frontend
|
||||
channel`_.
|
||||
|
||||
For anything non-trivial, the best path is to open an issue in this repository
|
||||
with as many details about the issue you are facing as you can provide.
|
||||
|
||||
https://github.com/openedx/frontend-app-course-authoring/issues
|
||||
|
||||
For more information about these options, see the `Getting Help`_ page.
|
||||
|
||||
.. _Slack invitation: https://openedx.org/slack
|
||||
.. _community Slack workspace: https://openedx.slack.com/
|
||||
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
|
||||
.. _Getting Help: https://openedx.org/community/connect
|
||||
|
||||
|
||||
License
|
||||
*******
|
||||
|
||||
The code in this repository is licensed under the AGPLv3 unless otherwise
|
||||
noted.
|
||||
|
||||
Please see `LICENSE <LICENSE>`_ for details.
|
||||
|
||||
|
||||
Contributing
|
||||
************
|
||||
|
||||
Contributions are very welcome. Please read `How To Contribute`_ for details.
|
||||
|
||||
.. _How To Contribute: https://openedx.org/r/how-to-contribute
|
||||
|
||||
This project is currently accepting all types of contributions, bug fixes,
|
||||
security fixes, maintenance work, or new features. However, please make sure
|
||||
to have a discussion about your new feature idea with the maintainers prior to
|
||||
beginning development to maximize the chances of your change being accepted.
|
||||
You can start a conversation by creating a new issue on this repo summarizing
|
||||
your idea.
|
||||
|
||||
|
||||
The Open edX Code of Conduct
|
||||
****************************
|
||||
|
||||
All community members are expected to follow the `Open edX Code of Conduct`_.
|
||||
|
||||
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
|
||||
|
||||
People
|
||||
******
|
||||
|
||||
The assigned maintainers for this component and other project details may be
|
||||
found in `Backstage`_. Backstage pulls this data from the ``catalog-info.yaml``
|
||||
file in this repo.
|
||||
|
||||
.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/frontend-app-course-authoring
|
||||
|
||||
|
||||
Reporting Security Issues
|
||||
*************************
|
||||
|
||||
Please do not report security issues in public, and email security@openedx.org instead.
|
||||
|
||||
.. |license-badge| image:: https://img.shields.io/github/license/openedx/frontend-app-course-authoring.svg
|
||||
:target: https://github.com/openedx/frontend-app-course-authoring/blob/master/LICENSE
|
||||
:alt: License
|
||||
|
||||
.. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen
|
||||
|
||||
.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-course-authoring/coverage.svg?branch=master
|
||||
:target: https://codecov.io/github/openedx/frontend-app-course-authoring?branch=master
|
||||
:alt: Codecov
|
||||
|
||||
@@ -8,3 +8,6 @@ coverage:
|
||||
default:
|
||||
target: auto
|
||||
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', {
|
||||
setupFilesAfterEnv: [
|
||||
'jest-expect-message',
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
'src/setupTest.js',
|
||||
'src/i18n',
|
||||
],
|
||||
snapshotSerializers: [
|
||||
'enzyme-to-json/serializer',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^lodash-es$': 'lodash',
|
||||
},
|
||||
modulePathIgnorePatterns: [
|
||||
'/src/pages-and-resources/utils.test.jsx',
|
||||
],
|
||||
});
|
||||
|
||||
41942
package-lock.json
generated
94
package.json
@@ -11,12 +11,15 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"i18n_extract": "fedx-scripts formatjs extract",
|
||||
"stylelint": "stylelint \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
|
||||
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
"start:with-theme": "paragon install-theme && npm start && npm install",
|
||||
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
|
||||
"types": "tsc --noEmit"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
@@ -33,51 +36,70 @@
|
||||
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "11.1.1",
|
||||
"@edx/frontend-lib-content-components": "^1.131.0",
|
||||
"@edx/frontend-platform": "2.5.1",
|
||||
"@edx/paragon": "^20.28.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.28",
|
||||
"@fortawesome/free-brands-svg-icons": "5.11.2",
|
||||
"@fortawesome/free-regular-svg-icons": "5.11.2",
|
||||
"@fortawesome/free-solid-svg-icons": "5.11.2",
|
||||
"@fortawesome/react-fontawesome": "0.1.9",
|
||||
"@reduxjs/toolkit": "1.5.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-ai-translations": "^1.4.0",
|
||||
"@edx/frontend-component-footer": "^12.3.0",
|
||||
"@edx/frontend-component-header": "^4.7.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^2.0.0",
|
||||
"@edx/frontend-lib-content-components": "^1.178.2",
|
||||
"@edx/frontend-platform": "5.6.1",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@edx/paragon": "^21.5.6",
|
||||
"@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",
|
||||
"core-js": "3.8.1",
|
||||
"email-validator": "2.0.4",
|
||||
"file-saver": "^2.0.5",
|
||||
"formik": "2.2.6",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"moment": "2.29.2",
|
||||
"moment": "2.29.4",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"react": "17.0.2",
|
||||
"react-datepicker": "^4.13.0",
|
||||
"react-dom": "17.0.2",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-redux": "7.1.3",
|
||||
"react-responsive": "8.1.0",
|
||||
"react-router": "5.1.2",
|
||||
"react-router-dom": "5.1.2",
|
||||
"react-transition-group": "4.4.1",
|
||||
"react-redux": "7.2.9",
|
||||
"react-responsive": "9.0.2",
|
||||
"react-router": "6.16.0",
|
||||
"react-router-dom": "6.16.0",
|
||||
"react-textarea-autosize": "^8.4.1",
|
||||
"react-transition-group": "4.4.5",
|
||||
"redux": "4.0.5",
|
||||
"regenerator-runtime": "0.13.7",
|
||||
"universal-cookie": "^4.0.4",
|
||||
"uuid": "^3.4.0",
|
||||
"yup": "0.31.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.0.0",
|
||||
"@edx/frontend-build": "12.3.0",
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-build": "13.0.5",
|
||||
"@edx/react-unit-test-utils": "^1.7.0",
|
||||
"@edx/reactifex": "^1.0.3",
|
||||
"@testing-library/jest-dom": "5.16.4",
|
||||
"@testing-library/react": "12.1.1",
|
||||
"@edx/stylelint-config-edx": "^2.3.0",
|
||||
"@edx/typescript-config": "^1.0.1",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"axios-mock-adapter": "1.20.0",
|
||||
"enzyme": "3.11.0",
|
||||
"enzyme-adapter-react-16": "1.15.6",
|
||||
"enzyme-to-json": "^3.6.2",
|
||||
"glob": "7.1.6",
|
||||
"husky": "3.1.0",
|
||||
"react-test-renderer": "16.9.0",
|
||||
"reactifex": "1.1.1"
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"glob": "7.2.3",
|
||||
"husky": "^7.0.4",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"react-test-renderer": "17.0.2",
|
||||
"reactifex": "1.1.1",
|
||||
"ts-loader": "^9.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"decode-uri-component": ">=0.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<title>Course Authoring | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="<%= process.env.FAVICON_URL %>" type="image/x-icon" />
|
||||
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base",
|
||||
"schedule:daily",
|
||||
"schedule:weekly",
|
||||
":rebaseStalePrs",
|
||||
":semanticCommits"
|
||||
":semanticCommits",
|
||||
":dependencyDashboard"
|
||||
],
|
||||
"timezone": "America/New_York",
|
||||
"patch": {
|
||||
"automerge": true
|
||||
"automerge": false
|
||||
},
|
||||
"rebaseStalePrs": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["@edx"],
|
||||
"extends": [
|
||||
"schedule:daily"
|
||||
],
|
||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@edx/frontend-lib-content-components"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": false,
|
||||
"schedule": [
|
||||
"after 1am",
|
||||
"before 11pm"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import Header from './studio-header/Header';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import Header from './header';
|
||||
import { fetchCourseDetail } from './data/thunks';
|
||||
import { useModel } from './generic/model-store';
|
||||
import NotFoundAlert from './generic/NotFoundAlert';
|
||||
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
||||
import { getCourseAppsApiStatus, getLoadingStatus } from './pages-and-resources/data/selectors';
|
||||
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
|
||||
import { RequestStatus } from './data/constants';
|
||||
import Loading from './generic/Loading';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from './generic/data/thunks';
|
||||
import { getUserPermissions } from './generic/data/selectors';
|
||||
|
||||
const AppHeader = ({
|
||||
courseNumber, courseOrg, courseTitle, courseId,
|
||||
@@ -37,17 +40,16 @@ AppHeader.defaultProps = {
|
||||
courseOrg: null,
|
||||
};
|
||||
|
||||
const AppFooter = () => (
|
||||
<div className="mt-6">
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
const userPermissions = useSelector(getUserPermissions);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseDetail(courseId));
|
||||
dispatch(fetchUserPermissionsEnabledFlag());
|
||||
if (!userPermissions) {
|
||||
dispatch(fetchUserPermissionsQuery(courseId));
|
||||
}
|
||||
}, [courseId]);
|
||||
|
||||
const courseDetail = useModel('courseDetails', courseId);
|
||||
@@ -56,31 +58,39 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
const courseOrg = courseDetail ? courseDetail.org : null;
|
||||
const courseTitle = courseDetail ? courseDetail.name : courseId;
|
||||
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
|
||||
const inProgress = useSelector(getLoadingStatus) === RequestStatus.IN_PROGRESS;
|
||||
const courseDetailStatus = useSelector(state => state.courseDetail.status);
|
||||
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS;
|
||||
const { pathname } = useLocation();
|
||||
const isEditor = pathname.includes('/editor');
|
||||
|
||||
if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) {
|
||||
return (
|
||||
<NotFoundAlert />
|
||||
);
|
||||
}
|
||||
if (courseAppsApiStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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/,
|
||||
we shouldn't have the header and footer on these pages.
|
||||
This functionality will be removed in TNL-9591 */}
|
||||
{inProgress ? !pathname.includes('/editor/') && <Loading />
|
||||
: (
|
||||
{inProgress ? !isEditor && <Loading />
|
||||
: (!isEditor && (
|
||||
<AppHeader
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
)}
|
||||
)
|
||||
)}
|
||||
{children}
|
||||
{!inProgress && <AppFooter />}
|
||||
{!inProgress && !isEditor && <StudioFooter />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { queryByTestId, render } from '@testing-library/react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
@@ -12,6 +12,7 @@ import CourseAuthoringPage from './CourseAuthoringPage';
|
||||
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
||||
import { executeThunk } from './utils';
|
||||
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
||||
import { fetchCourseDetail } from './data/thunks';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
let mockPathname = '/evilguy/';
|
||||
@@ -23,50 +24,18 @@ jest.mock('react-router-dom', () => ({
|
||||
}));
|
||||
let axiosMock;
|
||||
let store;
|
||||
let container;
|
||||
function renderComponent() {
|
||||
const wrapper = render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</CourseAuthoringPage>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
const mockStore = async () => {
|
||||
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const courseAppsApiUrl = `${apiBaseUrl}/api/course_apps/v1/apps`;
|
||||
axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(403, {
|
||||
response: { status: 403 },
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
};
|
||||
describe('DiscussionsSettings', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
test('renders permission error in case of 403', async () => {
|
||||
await mockStore();
|
||||
renderComponent();
|
||||
expect(queryByTestId(container, 'permissionDeniedAlert')).toBeInTheDocument();
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
describe('Editor Pages Load no header', () => {
|
||||
@@ -78,18 +47,6 @@ describe('Editor Pages Load no header', () => {
|
||||
});
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
};
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
test('renders no loading wheel on editor pages', async () => {
|
||||
mockPathname = '/editor/';
|
||||
await mockStoreSuccess();
|
||||
@@ -121,3 +78,56 @@ describe('Editor Pages Load no header', () => {
|
||||
expect(wrapper.queryByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course authoring page', () => {
|
||||
const lmsApiBaseUrl = getConfig().LMS_BASE_URL;
|
||||
const courseDetailApiUrl = `${lmsApiBaseUrl}/api/courses/v1/courses`;
|
||||
const mockStoreNotFound = async () => {
|
||||
axiosMock.onGet(
|
||||
`${courseDetailApiUrl}/${courseId}?username=abc123`,
|
||||
).reply(404, {
|
||||
response: { status: 404 },
|
||||
});
|
||||
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
|
||||
};
|
||||
const mockStoreError = async () => {
|
||||
axiosMock.onGet(
|
||||
`${courseDetailApiUrl}/${courseId}?username=abc123`,
|
||||
).reply(500, {
|
||||
response: { status: 500 },
|
||||
});
|
||||
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
|
||||
};
|
||||
test('renders not found page on non-existent course key', async () => {
|
||||
await mockStoreNotFound();
|
||||
const wrapper = render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
|
||||
});
|
||||
test('does not render not found page on other kinds of error', async () => {
|
||||
await mockStoreError();
|
||||
// Currently, loading errors are not handled, so we wait for the child
|
||||
// content to be rendered -which happens when request status is no longer
|
||||
// IN_PROGRESS but also not NOT_FOUND or DENIED- then check that the not
|
||||
// found alert is not present.
|
||||
const contentTestId = 'courseAuthoringPageContent';
|
||||
const wrapper = render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<div data-testid={contentTestId} />
|
||||
</CourseAuthoringPage>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
|
||||
expect(wrapper.queryByTestId('notFoundAlert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Switch, useRouteMatch } from 'react-router';
|
||||
import { PageRoute } from '@edx/frontend-platform/react';
|
||||
import {
|
||||
Navigate, Routes, Route, useParams,
|
||||
} from 'react-router-dom';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { PageWrap } from '@edx/frontend-platform/react';
|
||||
import CourseAuthoringPage from './CourseAuthoringPage';
|
||||
import { PagesAndResources } from './pages-and-resources';
|
||||
import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings';
|
||||
import EditorContainer from './editors/EditorContainer';
|
||||
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
|
||||
import CustomPages from './custom-pages';
|
||||
import { FilesPage, VideosPage } from './files-and-videos';
|
||||
import { AdvancedSettings } from './advanced-settings';
|
||||
import { CourseOutline } from './course-outline';
|
||||
import ScheduleAndDetails from './schedule-and-details';
|
||||
import { GradingSettings } from './grading-settings';
|
||||
import CourseTeam from './course-team/CourseTeam';
|
||||
import { CourseUpdates } from './course-updates';
|
||||
import { CourseUnit } from './course-unit';
|
||||
import CourseExportPage from './export-page/CourseExportPage';
|
||||
import CourseImportPage from './import-page/CourseImportPage';
|
||||
import { DECODED_ROUTES } from './constants';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
|
||||
*/
|
||||
const CourseAuthoringRoutes = ({ courseId }) => {
|
||||
const { path } = useRouteMatch();
|
||||
const CourseAuthoringRoutes = () => {
|
||||
const { courseId } = useParams();
|
||||
|
||||
return (
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<Switch>
|
||||
<PageRoute path={`${path}/pages-and-resources`}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/proctored-exam-settings`}>
|
||||
<ProctoredExamSettings courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/editor/:blockType/:blockId`}>
|
||||
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
|
||||
&& (
|
||||
<EditorContainer
|
||||
courseId={courseId}
|
||||
/>
|
||||
)}
|
||||
</PageRoute>
|
||||
</Switch>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={<PageWrap><CourseOutline courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="course_info"
|
||||
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="assets"
|
||||
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="videos"
|
||||
element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? <PageWrap><VideosPage courseId={courseId} /></PageWrap> : null}
|
||||
/>
|
||||
<Route
|
||||
path="pages-and-resources/*"
|
||||
element={<PageWrap><PagesAndResources courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="proctored-exam-settings"
|
||||
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
|
||||
/>
|
||||
<Route
|
||||
path="custom-pages/*"
|
||||
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
|
||||
<Route
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
CourseAuthoringRoutes.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CourseAuthoringRoutes;
|
||||
|
||||
116
src/CourseAuthoringRoutes.test.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
|
||||
import initializeStore from './store';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const pagesAndResourcesMockText = 'Pages And Resources';
|
||||
const editorContainerMockText = 'Editor Container';
|
||||
const videoSelectorContainerMockText = 'Video Selector Container';
|
||||
const customPagesMockText = 'Custom Pages';
|
||||
let store;
|
||||
const mockComponentFn = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
courseId,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the TinyMceWidget from frontend-lib-content-components
|
||||
jest.mock('@edx/frontend-lib-content-components', () => ({
|
||||
TinyMceWidget: () => <div>Widget</div>,
|
||||
Footer: () => <div>Footer</div>,
|
||||
prepareEditorRef: jest.fn(() => ({
|
||||
refReady: true,
|
||||
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('./pages-and-resources/PagesAndResources', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return pagesAndResourcesMockText;
|
||||
});
|
||||
jest.mock('./editors/EditorContainer', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return editorContainerMockText;
|
||||
});
|
||||
jest.mock('./selectors/VideoSelectorContainer', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return videoSelectorContainerMockText;
|
||||
});
|
||||
jest.mock('./custom-pages/CustomPages', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return customPagesMockText;
|
||||
});
|
||||
|
||||
describe('<CourseAuthoringRoutes>', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
fit('renders the PagesAndResources component when the pages and resources route is active', () => {
|
||||
render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/pages-and-resources']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the EditorContainer component when the course editor route is active', () => {
|
||||
render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/editor/video/block-id']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the VideoSelectorContainer component when the course videos route is active', () => {
|
||||
render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/editor/course-videos/block-id']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
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';
|
||||