Compare commits
205 Commits
refactor--
...
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 |
14
.env
@@ -23,6 +23,7 @@ PUBLISHER_BASE_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=''
|
||||
STUDIO_SHORT_NAME='Studio'
|
||||
SUPPORT_EMAIL=''
|
||||
SUPPORT_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
@@ -30,17 +31,12 @@ ENABLE_ACCESSIBILITY_PAGE=false
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_NEW_HOME_PAGE = false
|
||||
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
|
||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
|
||||
ENABLE_NEW_GRADING_PAGE = false
|
||||
ENABLE_NEW_COURSE_TEAM_PAGE = false
|
||||
ENABLE_NEW_IMPORT_PAGE = false
|
||||
ENABLE_NEW_EXPORT_PAGE = false
|
||||
ENABLE_UNIT_PAGE = false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false
|
||||
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=''
|
||||
|
||||
@@ -25,6 +25,7 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='Your Plaform Name Here'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
STUDIO_SHORT_NAME='Studio'
|
||||
SUPPORT_EMAIL=
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
@@ -32,17 +33,12 @@ ENABLE_ACCESSIBILITY_PAGE=false
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_NEW_HOME_PAGE = false
|
||||
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
|
||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
|
||||
ENABLE_NEW_GRADING_PAGE = false
|
||||
ENABLE_NEW_COURSE_TEAM_PAGE = false
|
||||
ENABLE_NEW_IMPORT_PAGE = false
|
||||
ENABLE_NEW_EXPORT_PAGE = false
|
||||
ENABLE_UNIT_PAGE = false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false
|
||||
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'
|
||||
|
||||
13
.env.test
@@ -22,20 +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_NEW_HOME_PAGE = false
|
||||
ENABLE_NEW_COURSE_OUTLINE_PAGE = true
|
||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE = true
|
||||
ENABLE_NEW_GRADING_PAGE = true
|
||||
ENABLE_NEW_COURSE_TEAM_PAGE = true
|
||||
ENABLE_NEW_IMPORT_PAGE = true
|
||||
ENABLE_NEW_EXPORT_PAGE = true
|
||||
ENABLE_UNIT_PAGE = true
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = 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"
|
||||
|
||||
3
.gitignore
vendored
@@ -20,3 +20,6 @@ temp/babel-plugin-react-intl
|
||||
/temp
|
||||
/.vscode
|
||||
/module.config.js
|
||||
|
||||
# Local environment overrides
|
||||
.env.private
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"ignoreUnits": ["\\.5"]
|
||||
}],
|
||||
"property-no-vendor-prefix": [true, {
|
||||
"ignoreProperties": ["animation", "filter"]
|
||||
"ignoreProperties": ["animation", "filter", "transform", "transition"]
|
||||
}],
|
||||
"value-no-vendor-prefix": [true, {
|
||||
"ignoreValues": ["fill-available"]
|
||||
|
||||
14
Makefile
@@ -1,6 +1,6 @@
|
||||
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,it_IT,pt_PT,de_DE"
|
||||
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
|
||||
@@ -8,14 +8,14 @@ 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...
|
||||
@@ -54,12 +54,15 @@ pull_translations:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull --filter=$(transifex_langs) \
|
||||
&& 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) paragon frontend-component-footer 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.
|
||||
@@ -72,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
|
||||
|
||||
|
||||
118
README.rst
@@ -12,7 +12,6 @@ This is the Course Authoring micro-frontend, currently under development by `2U
|
||||
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
|
||||
************
|
||||
|
||||
@@ -32,6 +31,11 @@ to the `relevant tutor-mfe documentation`_ to get started using it.
|
||||
|
||||
.. _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
|
||||
===================
|
||||
|
||||
@@ -60,7 +64,7 @@ Cloning and Startup
|
||||
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
|
||||
or whatever port you setup.
|
||||
|
||||
********
|
||||
|
||||
Features
|
||||
********
|
||||
|
||||
@@ -69,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:
|
||||
|
||||
@@ -125,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
|
||||
@@ -145,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
|
||||
-------------------
|
||||
@@ -159,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:
|
||||
@@ -190,8 +191,85 @@ 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
|
||||
**********
|
||||
|
||||
@@ -200,8 +278,7 @@ Developing
|
||||
|
||||
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
|
||||
========================
|
||||
@@ -212,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
|
||||
*********
|
||||
|
||||
@@ -236,6 +313,7 @@ internationalization.
|
||||
|
||||
.. _frontend-platform i18n howto: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
|
||||
|
||||
|
||||
Getting Help
|
||||
************
|
||||
|
||||
@@ -259,6 +337,7 @@ For more information about these options, see the `Getting Help`_ page.
|
||||
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
|
||||
.. _Getting Help: https://openedx.org/community/connect
|
||||
|
||||
|
||||
License
|
||||
*******
|
||||
|
||||
@@ -267,6 +346,7 @@ noted.
|
||||
|
||||
Please see `LICENSE <LICENSE>`_ for details.
|
||||
|
||||
|
||||
Contributing
|
||||
************
|
||||
|
||||
@@ -281,6 +361,7 @@ 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
|
||||
****************************
|
||||
|
||||
@@ -297,6 +378,7 @@ file in this repo.
|
||||
|
||||
.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/frontend-app-course-authoring
|
||||
|
||||
|
||||
Reporting Security Issues
|
||||
*************************
|
||||
|
||||
@@ -310,4 +392,4 @@ Please do not report security issues in public, and email security@openedx.org i
|
||||
|
||||
.. |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
|
||||
: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',
|
||||
],
|
||||
});
|
||||
|
||||
16895
package-lock.json
generated
87
package.json
@@ -11,13 +11,15 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"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": "fedx-scripts jest --updateSnapshot",
|
||||
"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": {
|
||||
@@ -34,57 +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": "12.0.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^1.2.1",
|
||||
"@edx/frontend-lib-content-components": "^1.169.3",
|
||||
"@edx/frontend-platform": "4.2.0",
|
||||
"@edx/paragon": "^20.45.4",
|
||||
"@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": "17.0.2",
|
||||
"react-datepicker": "^4.13.0",
|
||||
"react-dom": "16.14.0",
|
||||
"react-dom": "17.0.2",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-ranger": "^2.1.0",
|
||||
"react-redux": "7.1.3",
|
||||
"react-responsive": "8.1.0",
|
||||
"react-router": "5.2.0",
|
||||
"react-router-dom": "5.2.0",
|
||||
"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.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.8.6",
|
||||
"@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",
|
||||
"@edx/stylelint-config-edx": "^2.3.0",
|
||||
"@testing-library/jest-dom": "5.16.4",
|
||||
"@testing-library/react": "12.1.1",
|
||||
"@edx/typescript-config": "^1.0.1",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,14 +5,17 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import { Footer } from '@edx/frontend-lib-content-components';
|
||||
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 } 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,26 +40,16 @@ AppHeader.defaultProps = {
|
||||
courseOrg: null,
|
||||
};
|
||||
|
||||
const AppFooter = () => (
|
||||
<div className="mt-6">
|
||||
<Footer
|
||||
marketingBaseUrl={process.env.MARKETING_SITE_BASE_URL}
|
||||
termsOfServiceUrl={process.env.TERMS_OF_SERVICE_URL}
|
||||
privacyPolicyUrl={process.env.PRIVACY_POLICY_URL}
|
||||
supportEmail={process.env.SUPPORT_EMAIL}
|
||||
platformName={process.env.SITE_NAME}
|
||||
lmsBaseUrl={process.env.LMS_BASE_URL}
|
||||
studioBaseUrl={process.env.STUDIO_BASE_URL}
|
||||
showAccessibilityPage={process.env.ENABLE_ACCESSIBILITY_PAGE === 'true'}
|
||||
/>
|
||||
</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);
|
||||
@@ -65,10 +58,16 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
const courseOrg = courseDetail ? courseDetail.org : null;
|
||||
const courseTitle = courseDetail ? courseDetail.name : courseId;
|
||||
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
|
||||
const inProgress = useSelector(state => state.courseDetail.status) === RequestStatus.IN_PROGRESS;
|
||||
const courseDetailStatus = useSelector(state => state.courseDetail.status);
|
||||
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS;
|
||||
const { pathname } = useLocation();
|
||||
const showHeader = !pathname.includes('/editor');
|
||||
const isEditor = pathname.includes('/editor');
|
||||
|
||||
if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) {
|
||||
return (
|
||||
<NotFoundAlert />
|
||||
);
|
||||
}
|
||||
if (courseAppsApiStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
@@ -80,8 +79,8 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
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 ? showHeader && <Loading />
|
||||
: (showHeader && (
|
||||
{inProgress ? !isEditor && <Loading />
|
||||
: (!isEditor && (
|
||||
<AppHeader
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
@@ -91,7 +90,7 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
)
|
||||
)}
|
||||
{children}
|
||||
{!inProgress && showHeader && <AppFooter />}
|
||||
{!inProgress && !isEditor && <StudioFooter />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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/';
|
||||
@@ -24,6 +25,19 @@ jest.mock('react-router-dom', () => ({
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
describe('Editor Pages Load no header', () => {
|
||||
const mockStoreSuccess = async () => {
|
||||
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
@@ -33,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();
|
||||
@@ -76,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,20 +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 Placeholder from '@edx/frontend-lib-content-components';
|
||||
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 FilesAndUploads from './files-and-uploads';
|
||||
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:
|
||||
@@ -32,91 +37,81 @@ import { CourseUpdates } from './course-updates';
|
||||
* 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}/outline`}>
|
||||
{process.env.ENABLE_NEW_COURSE_OUTLINE_PAGE === 'true'
|
||||
&& (
|
||||
<Placeholder />
|
||||
)}
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/course_info`}>
|
||||
<CourseUpdates courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/assets`}>
|
||||
<FilesAndUploads courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/videos`}>
|
||||
{process.env.ENABLE_NEW_VIDEO_UPLOAD_PAGE === 'true'
|
||||
&& (
|
||||
<Placeholder />
|
||||
)}
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/pages-and-resources`}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/proctored-exam-settings`}>
|
||||
<ProctoredExamSettings courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/custom-pages`}>
|
||||
<CustomPages courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/container/:blockId`}>
|
||||
{process.env.ENABLE_UNIT_PAGE === 'true'
|
||||
&& (
|
||||
<Placeholder />
|
||||
)}
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/editor/course-videos/:blockId`}>
|
||||
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
|
||||
&& (
|
||||
<VideoSelectorContainer
|
||||
courseId={courseId}
|
||||
/>
|
||||
)}
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/editor/:blockType/:blockId?`}>
|
||||
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
|
||||
&& (
|
||||
<EditorContainer
|
||||
courseId={courseId}
|
||||
/>
|
||||
)}
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/settings/details`}>
|
||||
<ScheduleAndDetails courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/settings/grading`}>
|
||||
<GradingSettings courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/course_team`}>
|
||||
<CourseTeam courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/settings/advanced`}>
|
||||
<AdvancedSettings courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/import`}>
|
||||
{process.env.ENABLE_NEW_IMPORT_PAGE === 'true'
|
||||
&& (
|
||||
<Placeholder />
|
||||
)}
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/export`}>
|
||||
{process.env.ENABLE_NEW_EXPORT_PAGE === 'true'
|
||||
&& (
|
||||
<Placeholder />
|
||||
)}
|
||||
</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;
|
||||
|
||||
@@ -8,13 +8,19 @@ import initializeStore from './store';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const pagesAndResourcesMockText = 'Pages And Resources';
|
||||
const proctoredExamSeetingsMockText = 'Proctored Exam Settings';
|
||||
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>,
|
||||
@@ -25,20 +31,10 @@ jest.mock('@edx/frontend-lib-content-components', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useRouteMatch: () => ({
|
||||
path: `/course/${courseId}`,
|
||||
}),
|
||||
}));
|
||||
jest.mock('./pages-and-resources/PagesAndResources', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return pagesAndResourcesMockText;
|
||||
});
|
||||
jest.mock('./proctored-exam-settings/ProctoredExamSettings', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return proctoredExamSeetingsMockText;
|
||||
});
|
||||
jest.mock('./editors/EditorContainer', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return editorContainerMockText;
|
||||
@@ -65,39 +61,16 @@ describe('<CourseAuthoringRoutes>', () => {
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
// TODO: This test needs to be corrected.
|
||||
// The problem arose after moving new commits (https://github.com/raccoongang/frontend-app-course-authoring/pull/25)
|
||||
it.skip('renders the PagesAndResources component when the pages and resources route is active', () => {
|
||||
fit('renders the PagesAndResources component when the pages and resources route is active', () => {
|
||||
render(
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={[`/course/${courseId}/pages-and-resources`]}>
|
||||
<CourseAuthoringRoutes courseId={courseId} />
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/pages-and-resources']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(proctoredExamSeetingsMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// TODO: This test needs to be corrected.
|
||||
// The problem arose after moving new commits (https://github.com/raccoongang/frontend-app-course-authoring/pull/25)
|
||||
it.skip('renders the ProctoredExamSettings component when the proctored exam settings route is active', () => {
|
||||
render(
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={[`/course/${courseId}/proctored-exam-settings`]}>
|
||||
<CourseAuthoringRoutes courseId={courseId} />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(proctoredExamSeetingsMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
@@ -107,9 +80,9 @@ describe('<CourseAuthoringRoutes>', () => {
|
||||
|
||||
it('renders the EditorContainer component when the course editor route is active', () => {
|
||||
render(
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={[`/course/${courseId}/editor/video/block-id`]}>
|
||||
<CourseAuthoringRoutes courseId={courseId} />
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/editor/video/block-id']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
@@ -125,9 +98,9 @@ describe('<CourseAuthoringRoutes>', () => {
|
||||
|
||||
it('renders the VideoSelectorContainer component when the course videos route is active', () => {
|
||||
render(
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={[`/course/${courseId}/editor/course-videos/block-id`]}>
|
||||
<CourseAuthoringRoutes courseId={courseId} />
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/editor/course-videos/block-id']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/
|
||||
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';
|
||||
@@ -23,11 +24,13 @@ import SettingsSidebar from './settings-sidebar/SettingsSidebar';
|
||||
import validateAdvancedSettingsData from './utils';
|
||||
import messages from './messages';
|
||||
import ModalError from './modal-error/ModalError';
|
||||
import { useAdvancedSettings } from './hooks';
|
||||
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);
|
||||
@@ -38,34 +41,66 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
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 {
|
||||
advancedSettingsData,
|
||||
isLoading,
|
||||
updateSettingsButtonState,
|
||||
proctoringErrors,
|
||||
mfeProctoredExamSettingsUrl,
|
||||
loadingSettingsStatus,
|
||||
savingStatus,
|
||||
} = useAdvancedSettings({
|
||||
dispatch,
|
||||
courseId,
|
||||
intl,
|
||||
setIsQueryPending,
|
||||
setShowSuccessAlert,
|
||||
setIsEditableState,
|
||||
showSaveSettingsPrompt,
|
||||
showErrorModal,
|
||||
setErrorFields,
|
||||
hasInternetConnectionError,
|
||||
});
|
||||
} = 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-contnt-center m-6">
|
||||
<div className="row justify-content-center m-6">
|
||||
<Placeholder />
|
||||
</div>
|
||||
);
|
||||
@@ -110,7 +145,7 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container size="xl" className="px-4">
|
||||
<Container size="xl" className="advanced-settings px-4">
|
||||
<div className="setting-header mt-5">
|
||||
{(proctoringErrors?.length > 0) && (
|
||||
<AlertProctoringError
|
||||
@@ -137,11 +172,6 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
) : null}
|
||||
</TransitionReplace>
|
||||
</div>
|
||||
<SubHeader
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
title={intl.formatMessage(messages.headingTitle)}
|
||||
contentTitle={intl.formatMessage(messages.policy)}
|
||||
/>
|
||||
<section className="setting-items mb-4">
|
||||
<Layout
|
||||
lg={[{ span: 9 }, { span: 3 }]}
|
||||
@@ -151,6 +181,11 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
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">
|
||||
@@ -195,6 +230,7 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
handleBlur={handleSettingBlur}
|
||||
isEditableState={isEditableState}
|
||||
setIsEditableState={setIsEditableState}
|
||||
disableForm={viewOnly}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,7 +3,11 @@ 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 {
|
||||
render,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import initializeStore from '../store';
|
||||
@@ -13,11 +17,15 @@ 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) => (
|
||||
@@ -43,11 +51,23 @@ const RootWrapper = () => (
|
||||
</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: 3,
|
||||
userId,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
@@ -58,7 +78,9 @@ describe('<AdvancedSettings />', () => {
|
||||
axiosMock
|
||||
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
|
||||
.reply(200, advancedSettingsMock);
|
||||
permissionsMockStore(userPermissionsData);
|
||||
});
|
||||
|
||||
it('should render without errors', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
@@ -161,4 +183,29 @@ describe('<AdvancedSettings />', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { fetchCourseAppSettings, fetchProctoringExamErrors } from './data/thunks';
|
||||
import {
|
||||
getCourseAppSettings, getSavingStatus, getProctoringExamErrors, getSendRequestErrors, getLoadingStatus,
|
||||
} from './data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const useAdvancedSettings = ({
|
||||
dispatch, courseId, intl, setIsQueryPending, setShowSuccessAlert, setIsEditableState, showSaveSettingsPrompt,
|
||||
showErrorModal, setErrorFields, hasInternetConnectionError,
|
||||
}) => {
|
||||
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]);
|
||||
|
||||
return {
|
||||
advancedSettingsData,
|
||||
isLoading,
|
||||
updateSettingsButtonState,
|
||||
proctoringErrors,
|
||||
mfeProctoredExamSettingsUrl,
|
||||
loadingSettingsStatus,
|
||||
savingStatus,
|
||||
};
|
||||
};
|
||||
@@ -1,23 +1,29 @@
|
||||
@import "variables";
|
||||
|
||||
.setting-items-policies {
|
||||
.setting-items-deprecated-setting {
|
||||
float: right;
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
.pgn__card-header .pgn__card-header-title-md {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,16 +46,11 @@
|
||||
|
||||
.form-control {
|
||||
min-height: 2.75rem;
|
||||
width: $setting-form-control-width;
|
||||
}
|
||||
|
||||
.pgn__card-section {
|
||||
max-width: $setting-form-control-width;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.pgn__card-header {
|
||||
padding: 0 0 0 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.pgn__card-status {
|
||||
@@ -59,8 +60,6 @@
|
||||
.pgn__card-header-content {
|
||||
margin-top: 1.438rem;
|
||||
margin-bottom: 1.438rem;
|
||||
display: flex;
|
||||
flex-direction: revert;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
$text-color-base: $gray-700;
|
||||
$setting-form-control-width: 34.375rem;
|
||||
|
||||
@@ -27,6 +27,7 @@ const SettingCard = ({
|
||||
setIsEditableState,
|
||||
// injected
|
||||
intl,
|
||||
disableForm,
|
||||
}) => {
|
||||
const { deprecated, help, displayName } = settingData;
|
||||
const initialValue = JSON.stringify(settingData.value, null, 4);
|
||||
@@ -58,8 +59,9 @@ const SettingCard = ({
|
||||
return (
|
||||
<li className="field-group course-advanced-policy-list-item">
|
||||
<Card className="flex-column setting-card">
|
||||
<Card.Body className="d-flex">
|
||||
<Card.Body className="d-flex row m-0 align-items-center">
|
||||
<Card.Header
|
||||
className="col-6"
|
||||
title={(
|
||||
<ActionRow>
|
||||
{capitalize(displayName)}
|
||||
@@ -86,10 +88,11 @@ const SettingCard = ({
|
||||
dangerouslySetInnerHTML={{ __html: help }}
|
||||
/>
|
||||
</ModalPopup>
|
||||
<ActionRow.Spacer />
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
<Card.Section>
|
||||
<Card.Section className="col-6 flex-grow-1">
|
||||
<Form.Group className="m-0">
|
||||
<Form.Control
|
||||
as={TextareaAutosize}
|
||||
@@ -98,6 +101,7 @@ const SettingCard = ({
|
||||
onChange={handleSettingChange}
|
||||
aria-label={displayName}
|
||||
onBlur={handleCardBlur}
|
||||
disabled={disableForm}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Card.Section>
|
||||
@@ -133,6 +137,7 @@ SettingCard.propTypes = {
|
||||
saveSettingsPrompt: PropTypes.bool.isRequired,
|
||||
isEditableState: PropTypes.bool.isRequired,
|
||||
setIsEditableState: PropTypes.func.isRequired,
|
||||
disableForm: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SettingCard);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import HelpSidebar from '../../generic/help-sidebar';
|
||||
import { HelpSidebar } from '../../generic/help-sidebar';
|
||||
import messages from './messages';
|
||||
|
||||
const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => (
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
.text-black {
|
||||
color: $black;
|
||||
}
|
||||
|
||||
.h-200px {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.mw-300px {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ 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 = {
|
||||
pending: 'pending',
|
||||
default: 'default',
|
||||
pending: 'pending',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
export const USER_ROLES = {
|
||||
@@ -19,7 +20,28 @@ export const BADGE_STATES = {
|
||||
};
|
||||
|
||||
export const NOTIFICATION_MESSAGES = {
|
||||
adding: 'Adding',
|
||||
saving: 'Saving',
|
||||
duplicating: 'Duplicating',
|
||||
deleting: 'Deleting',
|
||||
copying: 'Copying',
|
||||
pasting: 'Pasting',
|
||||
empty: '',
|
||||
};
|
||||
|
||||
export const DEFAULT_TIME_STAMP = '00:00';
|
||||
|
||||
export const COURSE_CREATOR_STATES = {
|
||||
unrequested: 'unrequested',
|
||||
pending: 'pending',
|
||||
granted: 'granted',
|
||||
denied: 'denied',
|
||||
disallowedForThisSite: 'disallowed_for_this_site',
|
||||
};
|
||||
|
||||
export const DECODED_ROUTES = {
|
||||
COURSE_UNIT: [
|
||||
'/container/:blockId/:sequenceId',
|
||||
'/container/:blockId',
|
||||
],
|
||||
};
|
||||
|
||||
223
src/content-tags-drawer/ContentTagsCollapsible.jsx
Normal file
@@ -0,0 +1,223 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Collapsible,
|
||||
SelectableBox,
|
||||
Button,
|
||||
ModalPopup,
|
||||
useToggle,
|
||||
SearchField,
|
||||
} from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { debounce } from 'lodash';
|
||||
import messages from './messages';
|
||||
import './ContentTagsCollapsible.scss';
|
||||
|
||||
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
|
||||
|
||||
import ContentTagsTree from './ContentTagsTree';
|
||||
|
||||
import useContentTagsCollapsibleHelper from './ContentTagsCollapsibleHelper';
|
||||
|
||||
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
|
||||
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
|
||||
|
||||
/**
|
||||
* Collapsible component that holds a Taxonomy along with Tags that belong to it.
|
||||
* This includes both applied tags and tags that are available to select
|
||||
* from a dropdown list.
|
||||
*
|
||||
* This component also handles all the logic with selecting/deselecting tags and keeps track of the
|
||||
* tags tree in the state. That is used to render the Tag bubbgles as well as the populating the
|
||||
* state of the tags in the dropdown selectors.
|
||||
*
|
||||
* The `contentTags` that is passed are consolidated and converted to a tree structure. For example:
|
||||
*
|
||||
* FROM:
|
||||
*
|
||||
* [
|
||||
* {
|
||||
* "value": "DNA Sequencing",
|
||||
* "lineage": [
|
||||
* "Science and Research",
|
||||
* "Genetics Subcategory",
|
||||
* "DNA Sequencing"
|
||||
* ]
|
||||
* },
|
||||
* {
|
||||
* "value": "Virology",
|
||||
* "lineage": [
|
||||
* "Science and Research",
|
||||
* "Molecular, Cellular, and Microbiology",
|
||||
* "Virology"
|
||||
* ]
|
||||
* }
|
||||
* ]
|
||||
*
|
||||
* TO:
|
||||
*
|
||||
* {
|
||||
* "Science and Research": {
|
||||
* explicit: false,
|
||||
* children: {
|
||||
* "Genetics Subcategory": {
|
||||
* explicit: false,
|
||||
* children: {
|
||||
* "DNA Sequencing": {
|
||||
* explicit: true,
|
||||
* children: {}
|
||||
* }
|
||||
* }
|
||||
* },
|
||||
* "Molecular, Cellular, and Microbiology": {
|
||||
* explicit: false,
|
||||
* children: {
|
||||
* "Virology": {
|
||||
* explicit: true,
|
||||
* children: {}
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* };
|
||||
*
|
||||
*
|
||||
* It also keeps track of newly added tags as they are selected in the dropdown selectors.
|
||||
* They are store in the same format above, and then merged to one tree that is used as the
|
||||
* source of truth for both the tag bubble and the dropdowns. They keys are order alphabetically.
|
||||
*
|
||||
* In the dropdowns, the value of each SelectableBox is stored along with it's lineage and is URI encoded.
|
||||
* Ths is so we are able to traverse and manipulate different parts of the tree leading to it.
|
||||
* Here is an example of what the value of the "Virology" tag would be:
|
||||
*
|
||||
* "Science%20and%20Research,Molecular%2C%20Cellular%2C%20and%20Microbiology,Virology"
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {string} props.contentId - Id of the content object
|
||||
* @param {TaxonomyData & {contentTags: ContentTagData[]}} props.taxonomyAndTagsData - Taxonomy metadata & applied tags
|
||||
*/
|
||||
const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData }) => {
|
||||
const intl = useIntl();
|
||||
const { id, name, canTagObject } = taxonomyAndTagsData;
|
||||
|
||||
const {
|
||||
tagChangeHandler, tagsTree, contentTagsCount, checkedTags,
|
||||
} = useContentTagsCollapsibleHelper(contentId, taxonomyAndTagsData);
|
||||
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [addTagsButtonRef, setAddTagsButtonRef] = React.useState(null);
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
|
||||
const handleSelectableBoxChange = React.useCallback((e) => {
|
||||
tagChangeHandler(e.target.value, e.target.checked);
|
||||
}, []);
|
||||
|
||||
const handleSearch = debounce((term) => {
|
||||
setSearchTerm(term.trim());
|
||||
}, 500); // Perform search after 500ms
|
||||
|
||||
const handleSearchChange = React.useCallback((value) => {
|
||||
if (value === '') {
|
||||
// No need to debounce when search term cleared. Clear debounce function
|
||||
handleSearch.cancel();
|
||||
setSearchTerm('');
|
||||
} else {
|
||||
handleSearch(value);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const modalPopupOnCloseHandler = React.useCallback((event) => {
|
||||
close(event);
|
||||
// Clear search term
|
||||
setSearchTerm('');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="d-flex">
|
||||
<Collapsible title={name} styling="card-lg" className="taxonomy-tags-collapsible">
|
||||
<div key={id}>
|
||||
<ContentTagsTree tagsTree={tagsTree} removeTagHandler={tagChangeHandler} />
|
||||
</div>
|
||||
|
||||
<div className="d-flex taxonomy-tags-selector-menu">
|
||||
|
||||
{canTagObject && (
|
||||
<Button
|
||||
ref={setAddTagsButtonRef}
|
||||
variant="outline-primary"
|
||||
onClick={open}
|
||||
>
|
||||
<FormattedMessage {...messages.addTagsButtonText} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ModalPopup
|
||||
hasArrow
|
||||
placement="bottom"
|
||||
positionRef={addTagsButtonRef}
|
||||
isOpen={isOpen}
|
||||
onClose={modalPopupOnCloseHandler}
|
||||
>
|
||||
<div className="bg-white p-3 shadow">
|
||||
|
||||
<SelectableBox.Set
|
||||
type="checkbox"
|
||||
name="tags"
|
||||
columns={1}
|
||||
ariaLabel={intl.formatMessage(messages.taxonomyTagsAriaLabel)}
|
||||
className="taxonomy-tags-selectable-box-set"
|
||||
onChange={handleSelectableBoxChange}
|
||||
value={checkedTags}
|
||||
>
|
||||
<SearchField
|
||||
onSubmit={() => {}}
|
||||
onChange={handleSearchChange}
|
||||
className="mb-2"
|
||||
/>
|
||||
|
||||
<ContentTagsDropDownSelector
|
||||
key={`selector-${id}`}
|
||||
taxonomyId={id}
|
||||
level={0}
|
||||
tagsTree={tagsTree}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
</SelectableBox.Set>
|
||||
</div>
|
||||
</ModalPopup>
|
||||
|
||||
</Collapsible>
|
||||
<div className="d-flex">
|
||||
<Badge
|
||||
variant="light"
|
||||
pill
|
||||
className={classNames('align-self-start', 'mt-3', {
|
||||
invisible: contentTagsCount === 0,
|
||||
})}
|
||||
>
|
||||
{contentTagsCount}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ContentTagsCollapsible.propTypes = {
|
||||
contentId: PropTypes.string.isRequired,
|
||||
taxonomyAndTagsData: PropTypes.shape({
|
||||
id: PropTypes.number,
|
||||
name: PropTypes.string,
|
||||
contentTags: PropTypes.arrayOf(PropTypes.shape({
|
||||
value: PropTypes.string,
|
||||
lineage: PropTypes.arrayOf(PropTypes.string),
|
||||
})),
|
||||
canTagObject: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default ContentTagsCollapsible;
|
||||
29
src/content-tags-drawer/ContentTagsCollapsible.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
.taxonomy-tags-collapsible {
|
||||
flex: 1;
|
||||
border: none !important;
|
||||
|
||||
.collapsible-trigger {
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.taxonomy-tags-selector-menu {
|
||||
button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.taxonomy-tags-selector-menu + div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.taxonomy-tags-selectable-box-set {
|
||||
grid-auto-rows: unset !important;
|
||||
grid-gap: unset !important;
|
||||
overflow-y: scroll;
|
||||
max-height: 20rem;
|
||||
}
|
||||
|
||||
.pgn__modal-popup__arrow {
|
||||
visibility: hidden;
|
||||
}
|
||||
262
src/content-tags-drawer/ContentTagsCollapsible.test.jsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
fireEvent,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import ContentTagsCollapsible from './ContentTagsCollapsible';
|
||||
import messages from './messages';
|
||||
import { useTaxonomyTagsData } from './data/apiHooks';
|
||||
|
||||
jest.mock('./data/apiHooks', () => ({
|
||||
useContentTaxonomyTagsUpdater: jest.fn(() => ({
|
||||
isError: false,
|
||||
mutate: jest.fn(),
|
||||
})),
|
||||
useTaxonomyTagsData: jest.fn(() => ({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
canAddTag: false,
|
||||
data: [],
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
const data = {
|
||||
contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
|
||||
taxonomyAndTagsData: {
|
||||
id: 123,
|
||||
name: 'Taxonomy 1',
|
||||
canTagObject: true,
|
||||
contentTags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 1.1',
|
||||
lineage: ['Tag 1', 'Tag 1.1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 2',
|
||||
lineage: ['Tag 2'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const ContentTagsCollapsibleComponent = ({ contentId, taxonomyAndTagsData }) => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<ContentTagsCollapsible contentId={contentId} taxonomyAndTagsData={taxonomyAndTagsData} />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
ContentTagsCollapsibleComponent.propTypes = ContentTagsCollapsible.propTypes;
|
||||
|
||||
describe('<ContentTagsCollapsible />', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers(); // To account for debounce timer
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers(); // Restore real timers after the tests
|
||||
});
|
||||
|
||||
async function getComponent(updatedData) {
|
||||
const componentData = (!updatedData ? data : updatedData);
|
||||
|
||||
return render(
|
||||
<ContentTagsCollapsibleComponent
|
||||
contentId={componentData.contentId}
|
||||
taxonomyAndTagsData={componentData.taxonomyAndTagsData}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function setupTaxonomyMock() {
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
hasMorePages: false,
|
||||
canAddTag: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12347,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('should render taxonomy tags data along content tags number badge', async () => {
|
||||
const { container, getByText } = await getComponent();
|
||||
expect(getByText('Taxonomy 1')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('badge').length).toBe(1);
|
||||
expect(getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render new tags as they are checked in the dropdown', async () => {
|
||||
setupTaxonomyMock();
|
||||
const { container, getByText, getAllByText } = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add tags" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on "Add tags" button to open dropdown to select new tags
|
||||
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
|
||||
fireEvent.click(addTagsButton);
|
||||
|
||||
// Wait for the dropdown selector for tags to open,
|
||||
// Tag 3 should only appear there
|
||||
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||
expect(getAllByText('Tag 3').length === 1);
|
||||
|
||||
const tag3 = getByText('Tag 3');
|
||||
|
||||
fireEvent.click(tag3);
|
||||
|
||||
// After clicking on Tag 3, it should also appear in amongst
|
||||
// the tag bubbles in the tree
|
||||
expect(getAllByText('Tag 3').length === 2);
|
||||
});
|
||||
|
||||
it('should remove tag when they are unchecked in the dropdown', async () => {
|
||||
setupTaxonomyMock();
|
||||
const { container, getByText, getAllByText } = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add tags" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Check that Tag 2 appears in tag bubbles
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
|
||||
// Click on "Add tags" button to open dropdown to select new tags
|
||||
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
|
||||
fireEvent.click(addTagsButton);
|
||||
|
||||
// Wait for the dropdown selector for tags to open,
|
||||
// Tag 3 should only appear there, (i.e. the dropdown is open, since Tag 3 is not applied)
|
||||
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||
|
||||
// Get the Tag 2 checkbox and click on it
|
||||
const tag2 = getAllByText('Tag 2')[1];
|
||||
fireEvent.click(tag2);
|
||||
|
||||
// After clicking on Tag 2, it should be removed from
|
||||
// the tag bubbles in so only the one in the dropdown appears
|
||||
expect(getAllByText('Tag 2').length === 1);
|
||||
});
|
||||
|
||||
it('should handle search term change', async () => {
|
||||
const {
|
||||
container, getByText, getByRole, getByDisplayValue,
|
||||
} = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add tags" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on "Add tags" button to open dropdown
|
||||
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
|
||||
fireEvent.click(addTagsButton);
|
||||
|
||||
// Get the search field
|
||||
const searchField = getByRole('searchbox');
|
||||
|
||||
const searchTerm = 'memo';
|
||||
|
||||
// Trigger a change in the search field
|
||||
userEvent.type(searchField, searchTerm);
|
||||
|
||||
await act(async () => {
|
||||
// Fast-forward time by 500 milliseconds (for the debounce delay)
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Check that the search term has been set
|
||||
expect(searchField).toHaveValue(searchTerm);
|
||||
expect(getByDisplayValue(searchTerm)).toBeInTheDocument();
|
||||
|
||||
// Clear search
|
||||
userEvent.clear(searchField);
|
||||
|
||||
// Check that the search term has been cleared
|
||||
expect(searchField).toHaveValue('');
|
||||
});
|
||||
|
||||
it('should close dropdown selector when clicking away', async () => {
|
||||
setupTaxonomyMock();
|
||||
const { container, getByText, queryByText } = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add tags" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on "Add tags" button to open dropdown
|
||||
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
|
||||
fireEvent.click(addTagsButton);
|
||||
|
||||
// Wait for the dropdown selector for tags to open, Tag 3 should appear
|
||||
// since it is not applied
|
||||
expect(queryByText('Tag 3')).toBeInTheDocument();
|
||||
|
||||
// Simulate clicking outside the dropdown remove focus
|
||||
userEvent.click(document.body);
|
||||
|
||||
// Simulate clicking outside the dropdown again to close it
|
||||
userEvent.click(document.body);
|
||||
|
||||
// Wait for the dropdown selector for tags to close, Tag 3 is no longer on
|
||||
// the page
|
||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render taxonomy tags data without tags number badge', async () => {
|
||||
const updatedData = { ...data };
|
||||
updatedData.taxonomyAndTagsData = { ...updatedData.taxonomyAndTagsData };
|
||||
updatedData.taxonomyAndTagsData.contentTags = [];
|
||||
const { container, getByText } = await getComponent(updatedData);
|
||||
|
||||
expect(getByText('Taxonomy 1')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('invisible').length).toBe(1);
|
||||
});
|
||||
});
|
||||
214
src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx
Normal file
@@ -0,0 +1,214 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import { useCheckboxSetValues } from '@edx/paragon';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import { useContentTaxonomyTagsUpdater } from './data/apiHooks';
|
||||
|
||||
/**
|
||||
* Util function that consolidates two tag trees into one, sorting the keys in
|
||||
* alphabetical order.
|
||||
*
|
||||
* @param {object} tree1 - first tag tree
|
||||
* @param {object} tree2 - second tag tree
|
||||
* @returns {object} merged tree containing both tree1 and tree2
|
||||
*/
|
||||
const mergeTrees = (tree1, tree2) => {
|
||||
const mergedTree = cloneDeep(tree1);
|
||||
|
||||
const sortKeysAlphabetically = (obj) => {
|
||||
const sortedObj = {};
|
||||
Object.keys(obj)
|
||||
.sort()
|
||||
.forEach((key) => {
|
||||
sortedObj[key] = obj[key];
|
||||
if (obj[key] && typeof obj[key] === 'object') {
|
||||
sortedObj[key].children = sortKeysAlphabetically(obj[key].children);
|
||||
}
|
||||
});
|
||||
return sortedObj;
|
||||
};
|
||||
|
||||
const mergeRecursively = (destination, source) => {
|
||||
Object.entries(source).forEach(([key, sourceValue]) => {
|
||||
const destinationValue = destination[key];
|
||||
|
||||
if (destinationValue && sourceValue && typeof destinationValue === 'object' && typeof sourceValue === 'object') {
|
||||
mergeRecursively(destinationValue, sourceValue);
|
||||
} else {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
destination[key] = cloneDeep(sourceValue);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
mergeRecursively(mergedTree, tree2);
|
||||
return sortKeysAlphabetically(mergedTree);
|
||||
};
|
||||
|
||||
/**
|
||||
* Util function that removes the tag along with its ancestors if it was
|
||||
* the only explicit child tag.
|
||||
*
|
||||
* @param {object} tree - tag tree to remove the tag from
|
||||
* @param {string[]} tagsToRemove - full lineage of tag to remove.
|
||||
* eg: ['grand parent', 'parent', 'tag']
|
||||
*/
|
||||
const removeTags = (tree, tagsToRemove) => {
|
||||
if (!tree || !tagsToRemove.length) {
|
||||
return;
|
||||
}
|
||||
const key = tagsToRemove[0];
|
||||
if (tree[key]) {
|
||||
removeTags(tree[key].children, tagsToRemove.slice(1));
|
||||
|
||||
if (Object.keys(tree[key].children).length === 0 && (tree[key].explicit === false || tagsToRemove.length === 1)) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
delete tree[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Handles all the underlying logic for the ContentTagsCollapsible component
|
||||
*/
|
||||
const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => {
|
||||
const {
|
||||
id, contentTags, canTagObject,
|
||||
} = taxonomyAndTagsData;
|
||||
// State to determine whether the tags are being updating so we can make a call
|
||||
// to the update endpoint to the reflect those changes
|
||||
const [updatingTags, setUpdatingTags] = React.useState(false);
|
||||
const updateTags = useContentTaxonomyTagsUpdater(contentId, id);
|
||||
|
||||
// Keeps track of the content objects tags count (both implicit and explicit)
|
||||
const [contentTagsCount, setContentTagsCount] = React.useState(0);
|
||||
|
||||
// Keeps track of the tree structure for tags that are add by selecting/unselecting
|
||||
// tags in the dropdowns.
|
||||
const [addedContentTags, setAddedContentTags] = React.useState({});
|
||||
|
||||
// To handle checking/unchecking tags in the SelectableBox
|
||||
const [checkedTags, { add, remove, clear }] = useCheckboxSetValues();
|
||||
|
||||
// Handles making requests to the update endpoint whenever the checked tags change
|
||||
React.useEffect(() => {
|
||||
// We have this check because this hook is fired when the component first loads
|
||||
// and reloads (on refocus). We only want to make a request to the update endpoint when
|
||||
// the user is updating the tags.
|
||||
if (updatingTags) {
|
||||
setUpdatingTags(false);
|
||||
const tags = checkedTags.map(t => decodeURIComponent(t.split(',').slice(-1)));
|
||||
updateTags.mutate({ tags });
|
||||
}
|
||||
}, [contentId, id, canTagObject, checkedTags]);
|
||||
|
||||
// This converts the contentTags prop to the tree structure mentioned above
|
||||
const appliedContentTags = React.useMemo(() => {
|
||||
let contentTagsCounter = 0;
|
||||
|
||||
// Clear all the tags that have not been commited and the checked boxes when
|
||||
// fresh contentTags passed in so the latest state from the backend is rendered
|
||||
setAddedContentTags({});
|
||||
clear();
|
||||
|
||||
// When an error occurs while updating, the contentTags query is invalidated,
|
||||
// hence they will be recalculated, and the updateTags mutation should be reset.
|
||||
if (updateTags.isError) {
|
||||
updateTags.reset();
|
||||
}
|
||||
|
||||
const resultTree = {};
|
||||
contentTags.forEach(item => {
|
||||
let currentLevel = resultTree;
|
||||
|
||||
item.lineage.forEach((key, index) => {
|
||||
if (!currentLevel[key]) {
|
||||
const isExplicit = index === item.lineage.length - 1;
|
||||
currentLevel[key] = {
|
||||
explicit: isExplicit,
|
||||
children: {},
|
||||
canChangeObjecttag: item.canChangeObjecttag,
|
||||
canDeleteObjecttag: item.canDeleteObjecttag,
|
||||
};
|
||||
|
||||
// Populating the SelectableBox with "selected" (explicit) tags
|
||||
const value = item.lineage.map(l => encodeURIComponent(l)).join(',');
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
isExplicit ? add(value) : remove(value);
|
||||
contentTagsCounter += 1;
|
||||
}
|
||||
|
||||
currentLevel = currentLevel[key].children;
|
||||
});
|
||||
});
|
||||
|
||||
setContentTagsCount(contentTagsCounter);
|
||||
return resultTree;
|
||||
}, [contentTags, updateTags.isError]);
|
||||
|
||||
// This is the source of truth that represents the current state of tags in
|
||||
// this Taxonomy as a tree. Whenever either the `appliedContentTags` (i.e. tags passed in
|
||||
// the prop from the backed) change, or when the `addedContentTags` (i.e. tags added by
|
||||
// selecting/unselecting them in the dropdown) change, the tree is recomputed.
|
||||
const tagsTree = React.useMemo(() => (
|
||||
mergeTrees(appliedContentTags, addedContentTags)
|
||||
), [appliedContentTags, addedContentTags]);
|
||||
|
||||
// Add tag to the tree, and while traversing remove any selected ancestor tags
|
||||
// as they should become implicit
|
||||
const addTags = (tree, tagLineage, selectedTag) => {
|
||||
const value = [];
|
||||
let traversal = tree;
|
||||
tagLineage.forEach(tag => {
|
||||
const isExplicit = selectedTag === tag;
|
||||
|
||||
if (!traversal[tag]) {
|
||||
traversal[tag] = {
|
||||
explicit: isExplicit,
|
||||
children: {},
|
||||
canChangeObjecttag: false,
|
||||
canDeleteObjecttag: false,
|
||||
};
|
||||
} else {
|
||||
traversal[tag].explicit = isExplicit;
|
||||
}
|
||||
|
||||
// Clear out the ancestor tags leading to newly selected tag
|
||||
// as they automatically become implicit
|
||||
value.push(encodeURIComponent(tag));
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
isExplicit ? add(value.join(',')) : remove(value.join(','));
|
||||
|
||||
traversal = traversal[tag].children;
|
||||
});
|
||||
};
|
||||
|
||||
const tagChangeHandler = React.useCallback((tagSelectableBoxValue, checked) => {
|
||||
const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t));
|
||||
const selectedTag = tagLineage.slice(-1)[0];
|
||||
|
||||
const addedTree = { ...addedContentTags };
|
||||
if (checked) {
|
||||
// We "add" the tag to the SelectableBox.Set inside the addTags method
|
||||
addTags(addedTree, tagLineage, selectedTag);
|
||||
} else {
|
||||
// Remove tag from the SelectableBox.Set
|
||||
remove(tagSelectableBoxValue);
|
||||
|
||||
// We remove them from both incase we are unselecting from an
|
||||
// existing applied Tag or a newly added one
|
||||
removeTags(addedTree, tagLineage);
|
||||
removeTags(appliedContentTags, tagLineage);
|
||||
}
|
||||
|
||||
setAddedContentTags(addedTree);
|
||||
setUpdatingTags(true);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
tagChangeHandler, tagsTree, contentTagsCount, checkedTags,
|
||||
};
|
||||
};
|
||||
|
||||
export default useContentTagsCollapsibleHelper;
|
||||
119
src/content-tags-drawer/ContentTagsDrawer.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// @ts-check
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
CloseButton,
|
||||
Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import messages from './messages';
|
||||
import ContentTagsCollapsible from './ContentTagsCollapsible';
|
||||
import { extractOrgFromContentId } from './utils';
|
||||
import {
|
||||
useContentTaxonomyTagsData,
|
||||
useContentData,
|
||||
} from './data/apiHooks';
|
||||
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
|
||||
import Loading from '../generic/Loading';
|
||||
|
||||
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
|
||||
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
|
||||
|
||||
const ContentTagsDrawer = () => {
|
||||
const intl = useIntl();
|
||||
const { contentId } = /** @type {{contentId: string}} */(useParams());
|
||||
|
||||
const org = extractOrgFromContentId(contentId);
|
||||
|
||||
const useTaxonomyListData = () => {
|
||||
const taxonomyListData = useTaxonomyListDataResponse(org);
|
||||
const isTaxonomyListLoaded = useIsTaxonomyListDataLoaded(org);
|
||||
return { taxonomyListData, isTaxonomyListLoaded };
|
||||
};
|
||||
|
||||
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId);
|
||||
const {
|
||||
data: contentTaxonomyTagsData,
|
||||
isSuccess: isContentTaxonomyTagsLoaded,
|
||||
} = useContentTaxonomyTagsData(contentId);
|
||||
const { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData();
|
||||
|
||||
const closeContentTagsDrawer = () => {
|
||||
// "*" allows communication with any origin
|
||||
window.parent.postMessage('closeManageTagsDrawer', '*');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleEsc = (event) => {
|
||||
/* Close drawer when ESC-key is pressed and selectable dropdown box not open */
|
||||
const selectableBoxOpen = document.querySelector('[data-selectable-box="taxonomy-tags"]');
|
||||
if (event.key === 'Escape' && !selectableBoxOpen) {
|
||||
closeContentTagsDrawer();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const taxonomies = useMemo(() => {
|
||||
if (taxonomyListData && contentTaxonomyTagsData) {
|
||||
// Initialize list of content tags in taxonomies to populate
|
||||
const taxonomiesList = taxonomyListData.results.map((taxonomy) => ({
|
||||
...taxonomy,
|
||||
contentTags: /** @type {ContentTagData[]} */([]),
|
||||
}));
|
||||
|
||||
const contentTaxonomies = contentTaxonomyTagsData.taxonomies;
|
||||
|
||||
// eslint-disable-next-line array-callback-return
|
||||
contentTaxonomies.map((contentTaxonomyTags) => {
|
||||
const contentTaxonomy = taxonomiesList.find((taxonomy) => taxonomy.id === contentTaxonomyTags.taxonomyId);
|
||||
if (contentTaxonomy) {
|
||||
contentTaxonomy.contentTags = contentTaxonomyTags.tags;
|
||||
}
|
||||
});
|
||||
|
||||
return taxonomiesList;
|
||||
}
|
||||
return [];
|
||||
}, [taxonomyListData, contentTaxonomyTagsData]);
|
||||
|
||||
return (
|
||||
|
||||
<div className="mt-1">
|
||||
<Container size="xl">
|
||||
<CloseButton onClick={() => closeContentTagsDrawer()} data-testid="drawer-close-button" />
|
||||
<span>{intl.formatMessage(messages.headerSubtitle)}</span>
|
||||
{ isContentDataLoaded
|
||||
? <h3>{ contentData.displayName }</h3>
|
||||
: (
|
||||
<div className="d-flex justify-content-center align-items-center flex-column">
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="xl"
|
||||
screenReaderText={intl.formatMessage(messages.loadingMessage)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr />
|
||||
|
||||
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded
|
||||
? taxonomies.map((data) => (
|
||||
<div key={`taxonomy-tags-collapsible-${data.id}`}>
|
||||
<ContentTagsCollapsible contentId={contentId} taxonomyAndTagsData={data} />
|
||||
<hr />
|
||||
</div>
|
||||
))
|
||||
: <Loading />}
|
||||
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentTagsDrawer;
|
||||
190
src/content-tags-drawer/ContentTagsDrawer.test.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { act, render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import ContentTagsDrawer from './ContentTagsDrawer';
|
||||
import {
|
||||
useContentTaxonomyTagsData,
|
||||
useContentData,
|
||||
} from './data/apiHooks';
|
||||
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('./data/apiHooks', () => ({
|
||||
useContentTaxonomyTagsData: jest.fn(() => ({
|
||||
isSuccess: false,
|
||||
data: {},
|
||||
})),
|
||||
useContentData: jest.fn(() => ({
|
||||
isSuccess: false,
|
||||
data: {},
|
||||
})),
|
||||
useContentTaxonomyTagsUpdater: jest.fn(() => ({
|
||||
isError: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../taxonomy/data/apiHooks', () => ({
|
||||
useTaxonomyListDataResponse: jest.fn(),
|
||||
useIsTaxonomyListDataLoaded: jest.fn(),
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<ContentTagsDrawer />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('<ContentTagsDrawer />', () => {
|
||||
it('should render page and page title correctly', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText('Manage tags')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows spinner before the content data query is complete', async () => {
|
||||
await act(async () => {
|
||||
const { getAllByRole } = render(<RootWrapper />);
|
||||
const spinner = getAllByRole('status')[0];
|
||||
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
|
||||
});
|
||||
});
|
||||
|
||||
it('shows spinner before the taxonomy tags query is complete', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(false);
|
||||
await act(async () => {
|
||||
const { getAllByRole } = render(<RootWrapper />);
|
||||
const spinner = getAllByRole('status')[1];
|
||||
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the content display name after the query is complete', async () => {
|
||||
useContentData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
displayName: 'Unit 1',
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText('Unit 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||
useContentTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
taxonomies: [
|
||||
{
|
||||
name: 'Taxonomy 1',
|
||||
taxonomyId: 123,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 2',
|
||||
lineage: ['Tag 2'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Taxonomy 2',
|
||||
taxonomyId: 124,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 3',
|
||||
lineage: ['Tag 3'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
useTaxonomyListDataResponse.mockReturnValue({
|
||||
results: [{
|
||||
id: 123,
|
||||
name: 'Taxonomy 1',
|
||||
description: 'This is a description 1',
|
||||
canTagObject: false,
|
||||
}, {
|
||||
id: 124,
|
||||
name: 'Taxonomy 2',
|
||||
description: 'This is a description 2',
|
||||
canTagObject: false,
|
||||
}],
|
||||
});
|
||||
await act(async () => {
|
||||
const { container, getByText } = render(<RootWrapper />);
|
||||
expect(getByText('Taxonomy 1')).toBeInTheDocument();
|
||||
expect(getByText('Taxonomy 2')).toBeInTheDocument();
|
||||
const tagCountBadges = container.getElementsByClassName('badge');
|
||||
expect(tagCountBadges[0].textContent).toBe('2');
|
||||
expect(tagCountBadges[1].textContent).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
it('should call closeContentTagsDrawer when CloseButton is clicked', async () => {
|
||||
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
|
||||
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
|
||||
// Find the CloseButton element by its test ID and trigger a click event
|
||||
const closeButton = getByTestId('drawer-close-button');
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(postMessageSpy).toHaveBeenCalledWith('closeManageTagsDrawer', '*');
|
||||
|
||||
postMessageSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should call closeContentTagsDrawer when Escape key is pressed and no selectable box is active', () => {
|
||||
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
|
||||
|
||||
const { container } = render(<RootWrapper />);
|
||||
|
||||
fireEvent.keyDown(container, {
|
||||
key: 'Escape',
|
||||
});
|
||||
|
||||
expect(postMessageSpy).toHaveBeenCalledWith('closeManageTagsDrawer', '*');
|
||||
|
||||
postMessageSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not call closeContentTagsDrawer when Escape key is pressed and a selectable box is active', () => {
|
||||
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
|
||||
|
||||
const { container } = render(<RootWrapper />);
|
||||
|
||||
// Simulate that the selectable box is open by adding an element with the data attribute
|
||||
const selectableBox = document.createElement('div');
|
||||
selectableBox.setAttribute('data-selectable-box', 'taxonomy-tags');
|
||||
document.body.appendChild(selectableBox);
|
||||
|
||||
fireEvent.keyDown(container, {
|
||||
key: 'Escape',
|
||||
});
|
||||
|
||||
expect(postMessageSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Remove the added element
|
||||
document.body.removeChild(selectableBox);
|
||||
|
||||
postMessageSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
209
src/content-tags-drawer/ContentTagsDropDownSelector.jsx
Normal file
@@ -0,0 +1,209 @@
|
||||
// @ts-check
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
SelectableBox,
|
||||
Icon,
|
||||
Spinner,
|
||||
Button,
|
||||
} from '@edx/paragon';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { ArrowDropDown, ArrowDropUp } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import messages from './messages';
|
||||
import './ContentTagsDropDownSelector.scss';
|
||||
|
||||
import { useTaxonomyTagsData } from './data/apiHooks';
|
||||
|
||||
const HighlightedText = ({ text, highlight }) => {
|
||||
if (!highlight) {
|
||||
return <span>{text}</span>;
|
||||
}
|
||||
|
||||
const parts = text.split(new RegExp(`(${highlight})`, 'gi'));
|
||||
return (
|
||||
<span>
|
||||
{parts.map((part, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key -- using index because part is not unique
|
||||
<React.Fragment key={index}>
|
||||
{part.toLowerCase() === highlight.toLowerCase() ? <b>{part}</b> : part}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
HighlightedText.propTypes = {
|
||||
text: PropTypes.string.isRequired,
|
||||
highlight: PropTypes.string,
|
||||
};
|
||||
|
||||
HighlightedText.defaultProps = {
|
||||
highlight: '',
|
||||
};
|
||||
|
||||
const ContentTagsDropDownSelector = ({
|
||||
taxonomyId, level, lineage, tagsTree, searchTerm,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
// This object represents the states of the dropdowns on this level
|
||||
// The keys represent the index of the dropdown with
|
||||
// the value true (open) false (closed)
|
||||
const [dropdownStates, setDropdownStates] = useState(/** type Record<string, boolean> */ {});
|
||||
const isOpen = (tagValue) => dropdownStates[tagValue];
|
||||
|
||||
const [numPages, setNumPages] = useState(1);
|
||||
const parentTagValue = lineage.length ? decodeURIComponent(lineage[lineage.length - 1]) : null;
|
||||
const { hasMorePages, tagPages } = useTaxonomyTagsData(taxonomyId, parentTagValue, numPages, searchTerm);
|
||||
|
||||
const [prevSearchTerm, setPrevSearchTerm] = useState(searchTerm);
|
||||
|
||||
// Reset the page and tags state when search term changes
|
||||
// and store search term to compare
|
||||
if (prevSearchTerm !== searchTerm) {
|
||||
setPrevSearchTerm(searchTerm);
|
||||
setNumPages(1);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (tagPages.isSuccess) {
|
||||
if (searchTerm) {
|
||||
const expandAll = tagPages.data.reduce(
|
||||
(acc, tagData) => ({
|
||||
...acc,
|
||||
[tagData.value]: !!tagData.childCount,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
setDropdownStates(expandAll);
|
||||
} else {
|
||||
setDropdownStates({});
|
||||
}
|
||||
}
|
||||
}, [searchTerm, tagPages.isSuccess]);
|
||||
|
||||
const clickAndEnterHandler = (tagValue) => {
|
||||
// This flips the state of the dropdown at index false (closed) -> true (open)
|
||||
// and vice versa. Initially they are undefined which is falsy.
|
||||
setDropdownStates({ ...dropdownStates, [tagValue]: !dropdownStates[tagValue] });
|
||||
};
|
||||
|
||||
const isImplicit = (tag) => {
|
||||
// Traverse the tags tree using the lineage
|
||||
let traversal = tagsTree;
|
||||
lineage.forEach(t => {
|
||||
traversal = traversal[t]?.children || {};
|
||||
});
|
||||
|
||||
return (traversal[tag.value] && !traversal[tag.value].explicit) || false;
|
||||
};
|
||||
|
||||
const loadMoreTags = useCallback(() => {
|
||||
setNumPages((x) => x + 1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ marginLeft: `${level * 1 }rem` }}>
|
||||
{tagPages.isLoading ? (
|
||||
<div className="d-flex justify-content-center align-items-center flex-row">
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="xl"
|
||||
screenReaderText={intl.formatMessage(messages.loadingTagsDropdownMessage)}
|
||||
/>
|
||||
</div>
|
||||
) : null }
|
||||
{tagPages.isError ? 'Error...' : null /* TODO: show a proper error message */}
|
||||
|
||||
{tagPages.data?.map((tagData) => (
|
||||
<React.Fragment key={tagData.value}>
|
||||
<div
|
||||
className="d-flex flex-row"
|
||||
style={{
|
||||
minHeight: '44px',
|
||||
}}
|
||||
>
|
||||
<div className="d-flex">
|
||||
<SelectableBox
|
||||
inputHidden={false}
|
||||
type="checkbox"
|
||||
className="d-flex align-items-center taxonomy-tags-selectable-box"
|
||||
aria-label={intl.formatMessage(messages.taxonomyTagsCheckboxAriaLabel, { tag: tagData.value })}
|
||||
data-selectable-box="taxonomy-tags"
|
||||
value={[...lineage, tagData.value].map(t => encodeURIComponent(t)).join(',')}
|
||||
isIndeterminate={isImplicit(tagData)}
|
||||
disabled={isImplicit(tagData)}
|
||||
>
|
||||
<HighlightedText text={tagData.value} highlight={searchTerm} />
|
||||
</SelectableBox>
|
||||
{ tagData.childCount > 0
|
||||
&& (
|
||||
<div className="d-flex align-items-center taxonomy-tags-arrow-drop-down">
|
||||
<Icon
|
||||
src={isOpen(tagData.value) ? ArrowDropUp : ArrowDropDown}
|
||||
onClick={() => clickAndEnterHandler(tagData.value)}
|
||||
tabIndex="0"
|
||||
onKeyPress={(event) => (event.key === 'Enter' ? clickAndEnterHandler(tagData.value) : null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{ tagData.childCount > 0 && isOpen(tagData.value) && (
|
||||
<ContentTagsDropDownSelector
|
||||
taxonomyId={taxonomyId}
|
||||
level={level + 1}
|
||||
lineage={[...lineage, tagData.value]}
|
||||
tagsTree={tagsTree}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
)}
|
||||
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{ hasMorePages
|
||||
? (
|
||||
<div className="d-flex justify-content-center align-items-center flex-row">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
onClick={loadMoreTags}
|
||||
className="mb-2 taxonomy-tags-load-more-button"
|
||||
>
|
||||
<FormattedMessage {...messages.loadMoreTagsButtonText} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
|
||||
{ tagPages.data.length === 0 && !tagPages.isLoading && (
|
||||
<div className="d-flex justify-content-center muted-text">
|
||||
<FormattedMessage {...messages.noTagsFoundMessage} values={{ searchTerm }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ContentTagsDropDownSelector.defaultProps = {
|
||||
lineage: [],
|
||||
searchTerm: '',
|
||||
};
|
||||
|
||||
ContentTagsDropDownSelector.propTypes = {
|
||||
taxonomyId: PropTypes.number.isRequired,
|
||||
level: PropTypes.number.isRequired,
|
||||
lineage: PropTypes.arrayOf(PropTypes.string),
|
||||
tagsTree: PropTypes.objectOf(
|
||||
PropTypes.shape({
|
||||
explicit: PropTypes.bool.isRequired,
|
||||
children: PropTypes.shape({}).isRequired,
|
||||
}).isRequired,
|
||||
).isRequired,
|
||||
searchTerm: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ContentTagsDropDownSelector;
|
||||
21
src/content-tags-drawer/ContentTagsDropDownSelector.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
.taxonomy-tags-arrow-drop-down {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.taxonomy-tags-load-more-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pgn__selectable_box.taxonomy-tags-selectable-box {
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pgn__selectable_box.taxonomy-tags-selectable-box:disabled,
|
||||
.pgn__selectable_box.taxonomy-tags-selectable-box[disabled] {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.pgn__selectable_box-active.taxonomy-tags-selectable-box {
|
||||
outline: none !important;
|
||||
}
|
||||
368
src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from '@testing-library/react';
|
||||
|
||||
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
|
||||
import { useTaxonomyTagsData } from './data/apiHooks';
|
||||
|
||||
jest.mock('./data/apiHooks', () => ({
|
||||
useTaxonomyTagsData: jest.fn(() => ({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
data: [],
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
const data = {
|
||||
taxonomyId: 123,
|
||||
level: 0,
|
||||
tagsTree: {},
|
||||
};
|
||||
|
||||
const ContentTagsDropDownSelectorComponent = ({
|
||||
taxonomyId, level, lineage, tagsTree, searchTerm,
|
||||
}) => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<ContentTagsDropDownSelector
|
||||
taxonomyId={taxonomyId}
|
||||
level={level}
|
||||
lineage={lineage}
|
||||
tagsTree={tagsTree}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
ContentTagsDropDownSelectorComponent.defaultProps = {
|
||||
lineage: [],
|
||||
searchTerm: '',
|
||||
};
|
||||
|
||||
ContentTagsDropDownSelectorComponent.propTypes = ContentTagsDropDownSelector.propTypes;
|
||||
|
||||
describe('<ContentTagsDropDownSelector />', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render taxonomy tags drop down selector loading with spinner', async () => {
|
||||
await act(async () => {
|
||||
const { getByRole } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
/>,
|
||||
);
|
||||
const spinner = getByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading tags'); // Uses <Spinner />
|
||||
});
|
||||
});
|
||||
|
||||
it('should render taxonomy tags drop down selector with no sub tags', async () => {
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const { container, getByText } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
key={`selector-${data.taxonomyId}`}
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 1')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should render taxonomy tags drop down selector with sub tags', async () => {
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 1,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const { container, getByText } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand on click taxonomy tags drop down selector with sub tags', async () => {
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 1,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const dataWithTagsTree = {
|
||||
...data,
|
||||
tagsTree: {
|
||||
'Tag 3': {
|
||||
explicit: false,
|
||||
children: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { container, getByText } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
taxonomyId={dataWithTagsTree.taxonomyId}
|
||||
level={dataWithTagsTree.level}
|
||||
tagsTree={dataWithTagsTree.tagsTree}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||
});
|
||||
|
||||
// Mock useTaxonomyTagsData again since it gets called in the recursive call
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'Tag 2',
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
// Expand the dropdown to see the subtags selectors
|
||||
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand on enter key taxonomy tags drop down selector with sub tags', async () => {
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 1,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const dataWithTagsTree = {
|
||||
...data,
|
||||
tagsTree: {
|
||||
'Tag 3': {
|
||||
explicit: false,
|
||||
children: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { container, getByText } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
taxonomyId={dataWithTagsTree.taxonomyId}
|
||||
level={dataWithTagsTree.level}
|
||||
tagsTree={dataWithTagsTree.tagsTree}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||
});
|
||||
|
||||
// Mock useTaxonomyTagsData again since it gets called in the recursive call
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'Tag 2',
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
// Expand the dropdown to see the subtags selectors
|
||||
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
|
||||
fireEvent.keyPress(expandToggle, { key: 'Enter', charCode: 13 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should render taxonomy tags drop down selector and change search term', async () => {
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
const initalSearchTerm = 'test 1';
|
||||
await act(async () => {
|
||||
const { rerender } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
key={`selector-${data.taxonomyId}`}
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
searchTerm={initalSearchTerm}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, initalSearchTerm);
|
||||
});
|
||||
|
||||
const updatedSearchTerm = 'test 2';
|
||||
rerender(<ContentTagsDropDownSelectorComponent
|
||||
key={`selector-${data.taxonomyId}`}
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
searchTerm={updatedSearchTerm}
|
||||
/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, updatedSearchTerm);
|
||||
});
|
||||
|
||||
// Clean search term
|
||||
const cleanSearchTerm = '';
|
||||
rerender(<ContentTagsDropDownSelectorComponent
|
||||
key={`selector-${data.taxonomyId}`}
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
searchTerm={cleanSearchTerm}
|
||||
/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, cleanSearchTerm);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should render "noTag" message if search doesnt return taxonomies', async () => {
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data: [],
|
||||
},
|
||||
});
|
||||
|
||||
const searchTerm = 'uncommon search term';
|
||||
await act(async () => {
|
||||
const { getByText } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
key={`selector-${data.taxonomyId}`}
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
searchTerm={searchTerm}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
|
||||
});
|
||||
|
||||
const message = `No tags found with the search term "${searchTerm}"`;
|
||||
expect(getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
81
src/content-tags-drawer/ContentTagsTree.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import TagBubble from './TagBubble';
|
||||
|
||||
/**
|
||||
* Component that renders Tags under a Taxonomy in the nested tree format.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* {
|
||||
* "Science and Research": {
|
||||
* explicit: false,
|
||||
* children: {
|
||||
* "Genetics Subcategory": {
|
||||
* explicit: false,
|
||||
* children: {
|
||||
* "DNA Sequencing": {
|
||||
* explicit: true,
|
||||
* children: {}
|
||||
* }
|
||||
* }
|
||||
* },
|
||||
* "Molecular, Cellular, and Microbiology": {
|
||||
* explicit: false,
|
||||
* children: {
|
||||
* "Virology": {
|
||||
* explicit: true,
|
||||
* children: {}
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* };
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {Object} props.tagsTree - Array of taxonomy tags that are applied to the content.
|
||||
* @param {(
|
||||
* tagSelectableBoxValue: string,
|
||||
* checked: boolean
|
||||
* ) => void} props.removeTagHandler - Function that is called when removing tags from the tree.
|
||||
*/
|
||||
const ContentTagsTree = ({ tagsTree, removeTagHandler }) => {
|
||||
const renderTagsTree = (tag, level, lineage) => Object.keys(tag).map((key) => {
|
||||
const updatedLineage = [...lineage, encodeURIComponent(key)];
|
||||
if (tag[key] !== undefined) {
|
||||
return (
|
||||
<div key={`tag-${key}-level-${level}`}>
|
||||
<TagBubble
|
||||
key={`tag-${key}`}
|
||||
value={key}
|
||||
implicit={!tag[key].explicit}
|
||||
level={level}
|
||||
lineage={updatedLineage}
|
||||
removeTagHandler={removeTagHandler}
|
||||
canRemove={tag[key].canDeleteObjecttag}
|
||||
/>
|
||||
{ renderTagsTree(tag[key].children, level + 1, updatedLineage) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return <>{renderTagsTree(tagsTree, 0, [])}</>;
|
||||
};
|
||||
|
||||
ContentTagsTree.propTypes = {
|
||||
tagsTree: PropTypes.objectOf(
|
||||
PropTypes.shape({
|
||||
explicit: PropTypes.bool.isRequired,
|
||||
children: PropTypes.shape({}).isRequired,
|
||||
canDeleteObjecttag: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
).isRequired,
|
||||
removeTagHandler: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ContentTagsTree;
|
||||
57
src/content-tags-drawer/ContentTagsTree.test.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { act, render } from '@testing-library/react';
|
||||
|
||||
import ContentTagsTree from './ContentTagsTree';
|
||||
|
||||
const data = {
|
||||
'Science and Research': {
|
||||
explicit: false,
|
||||
canDeleteObjecttag: false,
|
||||
children: {
|
||||
'Genetics Subcategory': {
|
||||
explicit: false,
|
||||
children: {
|
||||
'DNA Sequencing': {
|
||||
explicit: true,
|
||||
children: {},
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
},
|
||||
canDeleteObjecttag: false,
|
||||
},
|
||||
'Molecular, Cellular, and Microbiology': {
|
||||
explicit: false,
|
||||
children: {
|
||||
Virology: {
|
||||
explicit: true,
|
||||
children: {},
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
},
|
||||
canDeleteObjecttag: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ContentTagsTreeComponent = ({ tagsTree, removeTagHandler }) => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<ContentTagsTree tagsTree={tagsTree} removeTagHandler={removeTagHandler} />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
ContentTagsTreeComponent.propTypes = ContentTagsTree.propTypes;
|
||||
|
||||
describe('<ContentTagsTree />', () => {
|
||||
it('should render taxonomy tags data along content tags number badge', async () => {
|
||||
await act(async () => {
|
||||
const { getByText } = render(<ContentTagsTreeComponent tagsTree={data} removeTagHandler={() => {}} />);
|
||||
expect(getByText('Science and Research')).toBeInTheDocument();
|
||||
expect(getByText('Genetics Subcategory')).toBeInTheDocument();
|
||||
expect(getByText('Molecular, Cellular, and Microbiology')).toBeInTheDocument();
|
||||
expect(getByText('DNA Sequencing')).toBeInTheDocument();
|
||||
expect(getByText('Virology')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
51
src/content-tags-drawer/TagBubble.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Chip,
|
||||
} from '@edx/paragon';
|
||||
import { Tag, Close } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import TagOutlineIcon from './TagOutlineIcon';
|
||||
|
||||
const TagBubble = ({
|
||||
value, implicit, level, lineage, removeTagHandler, canRemove,
|
||||
}) => {
|
||||
const className = `tag-bubble mb-2 border-light-300 ${implicit ? 'implicit' : ''}`;
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (!implicit && canRemove) {
|
||||
removeTagHandler(lineage.join(','), false);
|
||||
}
|
||||
}, [implicit, lineage, canRemove, removeTagHandler]);
|
||||
|
||||
return (
|
||||
<div style={{ paddingLeft: `${level * 1}rem` }}>
|
||||
<Chip
|
||||
className={className}
|
||||
variant="light"
|
||||
iconBefore={!implicit ? Tag : TagOutlineIcon}
|
||||
iconAfter={!implicit && canRemove ? Close : null}
|
||||
onIconAfterClick={handleClick}
|
||||
>
|
||||
{value}
|
||||
</Chip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TagBubble.defaultProps = {
|
||||
implicit: true,
|
||||
level: 0,
|
||||
canRemove: false,
|
||||
};
|
||||
|
||||
TagBubble.propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
implicit: PropTypes.bool,
|
||||
level: PropTypes.number,
|
||||
lineage: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
removeTagHandler: PropTypes.func.isRequired,
|
||||
canRemove: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default TagBubble;
|
||||
5
src/content-tags-drawer/TagBubble.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.tag-bubble.pgn__chip {
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
background-color: transparent;
|
||||
}
|
||||
109
src/content-tags-drawer/TagBubble.test.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import TagBubble from './TagBubble';
|
||||
|
||||
const data = {
|
||||
value: 'Tag 1',
|
||||
lineage: [],
|
||||
removeTagHandler: jest.fn(),
|
||||
};
|
||||
|
||||
const TagBubbleComponent = ({
|
||||
value, implicit, level, lineage, removeTagHandler, canRemove,
|
||||
}) => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<TagBubble
|
||||
value={value}
|
||||
implicit={implicit}
|
||||
level={level}
|
||||
lineage={lineage}
|
||||
removeTagHandler={removeTagHandler}
|
||||
canRemove={canRemove}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
TagBubbleComponent.defaultProps = {
|
||||
implicit: true,
|
||||
level: 0,
|
||||
canRemove: false,
|
||||
};
|
||||
|
||||
TagBubbleComponent.propTypes = TagBubble.propTypes;
|
||||
|
||||
describe('<TagBubble />', () => {
|
||||
it('should render implicit tag', () => {
|
||||
const { container, getByText } = render(
|
||||
<TagBubbleComponent
|
||||
value={data.value}
|
||||
lineage={data.lineage}
|
||||
removeTagHandler={data.removeTagHandler}
|
||||
/>,
|
||||
);
|
||||
expect(getByText(data.value)).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('implicit').length).toBe(1);
|
||||
expect(container.getElementsByClassName('pgn__chip__icon-after').length).toBe(0);
|
||||
});
|
||||
|
||||
it('should render explicit tag', () => {
|
||||
const tagBubbleData = {
|
||||
implicit: false,
|
||||
canRemove: true,
|
||||
...data,
|
||||
};
|
||||
const { container, getByText } = render(
|
||||
<TagBubbleComponent
|
||||
value={tagBubbleData.value}
|
||||
canRemove={tagBubbleData.canRemove}
|
||||
lineage={data.lineage}
|
||||
implicit={tagBubbleData.implicit}
|
||||
removeTagHandler={tagBubbleData.removeTagHandler}
|
||||
/>,
|
||||
);
|
||||
expect(getByText(`${tagBubbleData.value}`)).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('implicit').length).toBe(0);
|
||||
expect(container.getElementsByClassName('pgn__chip__icon-after').length).toBe(1);
|
||||
});
|
||||
|
||||
it('should call removeTagHandler when "x" clicked on explicit tag', async () => {
|
||||
const tagBubbleData = {
|
||||
implicit: false,
|
||||
canRemove: true,
|
||||
...data,
|
||||
};
|
||||
const { container } = render(
|
||||
<TagBubbleComponent
|
||||
value={tagBubbleData.value}
|
||||
canRemove={tagBubbleData.canRemove}
|
||||
lineage={data.lineage}
|
||||
implicit={tagBubbleData.implicit}
|
||||
removeTagHandler={tagBubbleData.removeTagHandler}
|
||||
/>,
|
||||
);
|
||||
|
||||
const xButton = container.getElementsByClassName('pgn__chip__icon-after')[0];
|
||||
fireEvent.click(xButton);
|
||||
expect(data.removeTagHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show "x" when canRemove is not allowed', async () => {
|
||||
const tagBubbleData = {
|
||||
implicit: false,
|
||||
canRemove: false,
|
||||
...data,
|
||||
};
|
||||
const { container } = render(
|
||||
<TagBubbleComponent
|
||||
value={tagBubbleData.value}
|
||||
canRemove={tagBubbleData.canRemove}
|
||||
lineage={data.lineage}
|
||||
implicit={tagBubbleData.implicit}
|
||||
removeTagHandler={tagBubbleData.removeTagHandler}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.getElementsByClassName('pgn__chip__icon-after')[0]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
20
src/content-tags-drawer/TagOutlineIcon.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
const TagOutlineIcon = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="m21.41 11.58-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM13 20.01 4 11V4h7v-.01l9 9-7 7.02z"
|
||||
/>
|
||||
<circle cx="6.5" cy="6.5" r="1.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default TagOutlineIcon;
|
||||
63
src/content-tags-drawer/__mocks__/contentDataMock.js
Normal file
@@ -0,0 +1,63 @@
|
||||
module.exports = {
|
||||
id: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
|
||||
displayName: 'Unit 1.1.2',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Nov 12, 2023 at 09:53 UTC',
|
||||
published: false,
|
||||
publishedOn: null,
|
||||
studioUrl: '/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
|
||||
releasedToStudents: false,
|
||||
releaseDate: null,
|
||||
visibilityState: 'needs_attention',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2030-01-01T00:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Lab',
|
||||
'Midterm Exam',
|
||||
'Final Exam',
|
||||
],
|
||||
hasChanges: true,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
taxonomyTagsWidgetUrl: 'http://localhost:2001/tagging/components/widget/',
|
||||
staffOnlyMessage: false,
|
||||
enableCopyPasteUnits: true,
|
||||
useTaggingTaxonomyListPage: true,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
};
|
||||
50
src/content-tags-drawer/__mocks__/contentTaxonomyTagsMock.js
Normal file
@@ -0,0 +1,50 @@
|
||||
module.exports = {
|
||||
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': {
|
||||
taxonomies: [
|
||||
{
|
||||
name: 'FlatTaxonomy',
|
||||
taxonomyId: 3,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'flat taxonomy tag 3856',
|
||||
lineage: [
|
||||
'flat taxonomy tag 3856',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'HierarchicalTaxonomy',
|
||||
taxonomyId: 4,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'hierarchical taxonomy tag 1.7.59',
|
||||
lineage: [
|
||||
'hierarchical taxonomy tag 1',
|
||||
'hierarchical taxonomy tag 1.7',
|
||||
'hierarchical taxonomy tag 1.7.59',
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'hierarchical taxonomy tag 2.13.46',
|
||||
lineage: [
|
||||
'hierarchical taxonomy tag 2',
|
||||
'hierarchical taxonomy tag 2.13',
|
||||
'hierarchical taxonomy tag 2.13.46',
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'hierarchical taxonomy tag 3.4.50',
|
||||
lineage: [
|
||||
'hierarchical taxonomy tag 3',
|
||||
'hierarchical taxonomy tag 3.4',
|
||||
'hierarchical taxonomy tag 3.4.50',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
4
src/content-tags-drawer/__mocks__/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as taxonomyTagsMock } from './taxonomyTagsMock';
|
||||
export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock';
|
||||
export { default as contentDataMock } from './contentDataMock';
|
||||
export { default as updateContentTaxonomyTagsMock } from './updateContentTaxonomyTagsMock';
|
||||
46
src/content-tags-drawer/__mocks__/taxonomyTagsMock.js
Normal file
@@ -0,0 +1,46 @@
|
||||
module.exports = {
|
||||
next: null,
|
||||
previous: null,
|
||||
count: 4,
|
||||
numPages: 1,
|
||||
currentPage: 1,
|
||||
start: 0,
|
||||
results: [
|
||||
{
|
||||
value: 'tag 1',
|
||||
externalId: null,
|
||||
childCount: 16,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 635951,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%201',
|
||||
},
|
||||
{
|
||||
value: 'tag 2',
|
||||
externalId: null,
|
||||
childCount: 16,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 636992,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%202',
|
||||
},
|
||||
{
|
||||
value: 'tag 3',
|
||||
externalId: null,
|
||||
childCount: 16,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 638033,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%203',
|
||||
},
|
||||
{
|
||||
value: 'tag 4',
|
||||
externalId: null,
|
||||
childCount: 16,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 639074,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%204',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': {
|
||||
taxonomies: [
|
||||
{
|
||||
name: 'FlatTaxonomy',
|
||||
taxonomyId: 3,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'flat taxonomy tag 100',
|
||||
lineage: [
|
||||
'flat taxonomy tag 100',
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'flat taxonomy tag 3856',
|
||||
lineage: [
|
||||
'flat taxonomy tag 3856',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
82
src/content-tags-drawer/data/api.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// @ts-check
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
/**
|
||||
* Get the URL used to fetch tags data from the "taxonomy tags" REST API
|
||||
* @param {number} taxonomyId
|
||||
* @param {{page?: number, searchTerm?: string, parentTag?: string}} options
|
||||
* @returns {string} the URL
|
||||
*/
|
||||
export const getTaxonomyTagsApiUrl = (taxonomyId, options = {}) => {
|
||||
const url = new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl());
|
||||
if (options.parentTag) {
|
||||
url.searchParams.append('parent_tag', options.parentTag);
|
||||
}
|
||||
if (options.page) {
|
||||
url.searchParams.append('page', String(options.page));
|
||||
}
|
||||
if (options.searchTerm) {
|
||||
url.searchParams.append('search_term', options.searchTerm);
|
||||
}
|
||||
|
||||
// Load in the full tree if children at once, if we can:
|
||||
// Note: do not combine this with page_size (we currently aren't using page_size)
|
||||
url.searchParams.append('full_depth_threshold', '1000');
|
||||
|
||||
return url.href;
|
||||
};
|
||||
export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tags/${contentId}/`, getApiBaseUrl()).href;
|
||||
export const getXBlockContentDataApiURL = (contentId) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href;
|
||||
export const getLibraryContentDataApiUrl = (contentId) => new URL(`/api/libraries/v2/blocks/${contentId}/`, getApiBaseUrl()).href;
|
||||
|
||||
/**
|
||||
* Get all tags that belong to taxonomy.
|
||||
* @param {number} taxonomyId The id of the taxonomy to fetch tags for
|
||||
* @param {{page?: number, searchTerm?: string, parentTag?: string}} options
|
||||
* @returns {Promise<import("../../taxonomy/tag-list/data/types.mjs").TagListData>}
|
||||
*/
|
||||
export async function getTaxonomyTagsData(taxonomyId, options = {}) {
|
||||
const url = getTaxonomyTagsApiUrl(taxonomyId, options);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tags that are applied to the content object
|
||||
* @param {string} contentId The id of the content object to fetch the applied tags for
|
||||
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
|
||||
*/
|
||||
export async function getContentTaxonomyTagsData(contentId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsApiUrl(contentId));
|
||||
return camelCaseObject(data[contentId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch meta data (eg: display_name) about the content object (unit/compoenent)
|
||||
* @param {string} contentId The id of the content object (unit/component)
|
||||
* @returns {Promise<import("./types.mjs").ContentData>}
|
||||
*/
|
||||
export async function getContentData(contentId) {
|
||||
const url = contentId.startsWith('lb:')
|
||||
? getLibraryContentDataApiUrl(contentId)
|
||||
: getXBlockContentDataApiURL(contentId);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update content object's applied tags
|
||||
* @param {string} contentId The id of the content object (unit/component)
|
||||
* @param {number} taxonomyId The id of the taxonomy the tags belong to
|
||||
* @param {string[]} tags The list of tags (values) to set on content object
|
||||
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
|
||||
*/
|
||||
export async function updateContentTaxonomyTags(contentId, taxonomyId, tags) {
|
||||
const url = getContentTaxonomyTagsApiUrl(contentId);
|
||||
const params = { taxonomy: taxonomyId };
|
||||
const { data } = await getAuthenticatedHttpClient().put(url, { tags }, { params });
|
||||
return camelCaseObject(data[contentId]);
|
||||
}
|
||||
119
src/content-tags-drawer/data/api.test.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// @ts-check
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import {
|
||||
taxonomyTagsMock,
|
||||
contentTaxonomyTagsMock,
|
||||
contentDataMock,
|
||||
updateContentTaxonomyTagsMock,
|
||||
} from '../__mocks__';
|
||||
|
||||
import {
|
||||
getTaxonomyTagsApiUrl,
|
||||
getContentTaxonomyTagsApiUrl,
|
||||
getXBlockContentDataApiURL,
|
||||
getLibraryContentDataApiUrl,
|
||||
getTaxonomyTagsData,
|
||||
getContentTaxonomyTagsData,
|
||||
getContentData,
|
||||
updateContentTaxonomyTags,
|
||||
} from './api';
|
||||
|
||||
let axiosMock;
|
||||
|
||||
describe('content tags drawer api calls', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get taxonomy tags data', async () => {
|
||||
const taxonomyId = 123;
|
||||
axiosMock.onGet().reply(200, taxonomyTagsMock);
|
||||
const result = await getTaxonomyTagsData(taxonomyId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(getTaxonomyTagsApiUrl(taxonomyId));
|
||||
expect(result).toEqual(taxonomyTagsMock);
|
||||
});
|
||||
|
||||
it('should get taxonomy tags data with parentTag', async () => {
|
||||
const taxonomyId = 123;
|
||||
const options = { parentTag: 'Sample Tag' };
|
||||
axiosMock.onGet().reply(200, taxonomyTagsMock);
|
||||
const result = await getTaxonomyTagsData(taxonomyId, options);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toContain('parent_tag=Sample+Tag');
|
||||
expect(result).toEqual(taxonomyTagsMock);
|
||||
});
|
||||
|
||||
it('should get taxonomy tags data with page', async () => {
|
||||
const taxonomyId = 123;
|
||||
const options = { page: 2 };
|
||||
axiosMock.onGet().reply(200, taxonomyTagsMock);
|
||||
const result = await getTaxonomyTagsData(taxonomyId, options);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toContain('page=2');
|
||||
expect(result).toEqual(taxonomyTagsMock);
|
||||
});
|
||||
|
||||
it('should get taxonomy tags data with searchTerm', async () => {
|
||||
const taxonomyId = 123;
|
||||
const options = { searchTerm: 'memo' };
|
||||
axiosMock.onGet().reply(200, taxonomyTagsMock);
|
||||
const result = await getTaxonomyTagsData(taxonomyId, options);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toContain('search_term=memo');
|
||||
expect(result).toEqual(taxonomyTagsMock);
|
||||
});
|
||||
|
||||
it('should get content taxonomy tags data', async () => {
|
||||
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
|
||||
axiosMock.onGet(getContentTaxonomyTagsApiUrl(contentId)).reply(200, contentTaxonomyTagsMock);
|
||||
const result = await getContentTaxonomyTagsData(contentId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsApiUrl(contentId));
|
||||
expect(result).toEqual(contentTaxonomyTagsMock[contentId]);
|
||||
});
|
||||
|
||||
it('should get content data for course component', async () => {
|
||||
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
|
||||
axiosMock.onGet(getXBlockContentDataApiURL(contentId)).reply(200, contentDataMock);
|
||||
const result = await getContentData(contentId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(getXBlockContentDataApiURL(contentId));
|
||||
expect(result).toEqual(contentDataMock);
|
||||
});
|
||||
|
||||
it('should get content data for V2 library component', async () => {
|
||||
const contentId = 'lb:SampleTaxonomyOrg1:NTL1:html:a3eded6b-2106-429a-98be-63533d563d79';
|
||||
axiosMock.onGet(getLibraryContentDataApiUrl(contentId)).reply(200, contentDataMock);
|
||||
const result = await getContentData(contentId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(getLibraryContentDataApiUrl(contentId));
|
||||
expect(result).toEqual(contentDataMock);
|
||||
});
|
||||
|
||||
it('should update content taxonomy tags', async () => {
|
||||
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
|
||||
const taxonomyId = 3;
|
||||
const tags = ['flat taxonomy tag 100', 'flat taxonomy tag 3856'];
|
||||
axiosMock.onPut(`${getContentTaxonomyTagsApiUrl(contentId)}`).reply(200, updateContentTaxonomyTagsMock);
|
||||
const result = await updateContentTaxonomyTags(contentId, taxonomyId, tags);
|
||||
|
||||
expect(axiosMock.history.put[0].url).toEqual(`${getContentTaxonomyTagsApiUrl(contentId)}`);
|
||||
expect(result).toEqual(updateContentTaxonomyTagsMock[contentId]);
|
||||
});
|
||||
});
|
||||
142
src/content-tags-drawer/data/apiHooks.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
// @ts-check
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
useQuery,
|
||||
useQueries,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import {
|
||||
getTaxonomyTagsData,
|
||||
getContentTaxonomyTagsData,
|
||||
getContentData,
|
||||
updateContentTaxonomyTags,
|
||||
} from './api';
|
||||
|
||||
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */
|
||||
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagData} TagData */
|
||||
|
||||
/**
|
||||
* Builds the query to get the taxonomy tags
|
||||
* @param {number} taxonomyId The id of the taxonomy to fetch tags for
|
||||
* @param {string|null} parentTag The tag whose children we're loading, if any
|
||||
* @param {string} searchTerm The term passed in to perform search on tags
|
||||
* @param {number} numPages How many pages of tags to load at this level
|
||||
*/
|
||||
export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1, searchTerm = '') => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const queryFn = async ({ queryKey }) => {
|
||||
const page = queryKey[3];
|
||||
return getTaxonomyTagsData(taxonomyId, { parentTag: parentTag || '', searchTerm, page });
|
||||
};
|
||||
|
||||
/** @type {{queryKey: any[], queryFn: typeof queryFn, staleTime: number}[]} */
|
||||
const queries = [];
|
||||
for (let page = 1; page <= numPages; page++) {
|
||||
queries.push(
|
||||
{ queryKey: ['taxonomyTags', taxonomyId, parentTag, page, searchTerm], queryFn, staleTime: Infinity },
|
||||
);
|
||||
}
|
||||
|
||||
const dataPages = useQueries({ queries });
|
||||
|
||||
const totalPages = dataPages[0]?.data?.numPages || 1;
|
||||
const hasMorePages = numPages < totalPages;
|
||||
|
||||
const tagPages = useMemo(() => {
|
||||
// Pre-load desendants if possible
|
||||
const preLoadedData = new Map();
|
||||
|
||||
const newTags = dataPages.map(result => {
|
||||
/** @type {TagData[]} */
|
||||
const simplifiedTagsList = [];
|
||||
|
||||
result.data?.results?.forEach((tag) => {
|
||||
if (tag.parentValue === parentTag) {
|
||||
simplifiedTagsList.push(tag);
|
||||
} else if (!preLoadedData.has(tag.parentValue)) {
|
||||
preLoadedData.set(tag.parentValue, [tag]);
|
||||
} else {
|
||||
preLoadedData.get(tag.parentValue).push(tag);
|
||||
}
|
||||
});
|
||||
|
||||
return { ...result, data: simplifiedTagsList };
|
||||
});
|
||||
|
||||
// Store the pre-loaded descendants into the query cache:
|
||||
preLoadedData.forEach((tags, parentValue) => {
|
||||
const queryKey = ['taxonomyTags', taxonomyId, parentValue, 1, searchTerm];
|
||||
/** @type {TagListData} */
|
||||
const cachedData = {
|
||||
next: '',
|
||||
previous: '',
|
||||
count: tags.length,
|
||||
numPages: 1,
|
||||
currentPage: 1,
|
||||
start: 0,
|
||||
results: tags,
|
||||
};
|
||||
queryClient.setQueryData(queryKey, cachedData);
|
||||
});
|
||||
|
||||
return newTags;
|
||||
}, [dataPages]);
|
||||
|
||||
const flatTagPages = {
|
||||
isLoading: tagPages.some(page => page.isLoading),
|
||||
isError: tagPages.some(page => page.isError),
|
||||
isSuccess: tagPages.every(page => page.isSuccess),
|
||||
data: tagPages.flatMap(page => page.data),
|
||||
};
|
||||
|
||||
return { hasMorePages, tagPages: flatTagPages };
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the query to get the taxonomy tags applied to the content object
|
||||
* @param {string} contentId The ID of the content object to fetch the applied tags for (e.g. an XBlock usage key)
|
||||
*/
|
||||
export const useContentTaxonomyTagsData = (contentId) => (
|
||||
useQuery({
|
||||
queryKey: ['contentTaxonomyTags', contentId],
|
||||
queryFn: () => getContentTaxonomyTagsData(contentId),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Builds the query to get meta data about the content object
|
||||
* @param {string} contentId The id of the content object (unit/component)
|
||||
*/
|
||||
export const useContentData = (contentId) => (
|
||||
useQuery({
|
||||
queryKey: ['contentData', contentId],
|
||||
queryFn: () => getContentData(contentId),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Builds the mutation to update the tags applied to the content object
|
||||
* @param {string} contentId The id of the content object to update tags for
|
||||
* @param {number} taxonomyId The id of the taxonomy the tags belong to
|
||||
*/
|
||||
export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
/**
|
||||
* @type {import("@tanstack/react-query").MutateFunction<
|
||||
* any,
|
||||
* any,
|
||||
* {
|
||||
* tags: string[]
|
||||
* }
|
||||
* >}
|
||||
*/
|
||||
mutationFn: ({ tags }) => updateContentTaxonomyTags(contentId, taxonomyId, tags),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
175
src/content-tags-drawer/data/apiHooks.test.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useQuery, useMutation, useQueries } from '@tanstack/react-query';
|
||||
import { act } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import {
|
||||
useTaxonomyTagsData,
|
||||
useContentTaxonomyTagsData,
|
||||
useContentData,
|
||||
useContentTaxonomyTagsUpdater,
|
||||
} from './apiHooks';
|
||||
|
||||
import { updateContentTaxonomyTags } from './api';
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: jest.fn(),
|
||||
useMutation: jest.fn(),
|
||||
useQueryClient: jest.fn(() => ({
|
||||
setQueryData: jest.fn(),
|
||||
})),
|
||||
useQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
updateContentTaxonomyTags: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('useTaxonomyTagsData', () => {
|
||||
it('should call useQueries with the correct arguments', () => {
|
||||
const taxonomyId = 123;
|
||||
const mockData = {
|
||||
results: [
|
||||
{
|
||||
value: 'tag 1',
|
||||
externalId: null,
|
||||
childCount: 16,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 635951,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%201',
|
||||
},
|
||||
{
|
||||
value: 'tag 2',
|
||||
externalId: null,
|
||||
childCount: 1,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 636992,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%202',
|
||||
},
|
||||
{
|
||||
value: 'tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'tag 2',
|
||||
id: 636993,
|
||||
subTagsUrl: null,
|
||||
},
|
||||
{
|
||||
value: 'tag 4',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'tag 2',
|
||||
id: 636994,
|
||||
subTagsUrl: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
useQueries.mockReturnValue([{
|
||||
data: mockData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
}]);
|
||||
|
||||
const { result } = renderHook(() => useTaxonomyTagsData(taxonomyId));
|
||||
|
||||
// Assert that useQueries was called with the correct arguments
|
||||
expect(useQueries).toHaveBeenCalledWith({
|
||||
queries: [
|
||||
{ queryKey: ['taxonomyTags', taxonomyId, null, 1, ''], queryFn: expect.any(Function), staleTime: Infinity },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.current.hasMorePages).toEqual(false);
|
||||
// Only includes the first 2 tags because the other 2 would be
|
||||
// in the nested dropdown
|
||||
expect(result.current.tagPages).toEqual(
|
||||
{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data: [
|
||||
{
|
||||
value: 'tag 1',
|
||||
externalId: null,
|
||||
childCount: 16,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 635951,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%201',
|
||||
},
|
||||
{
|
||||
value: 'tag 2',
|
||||
externalId: null,
|
||||
childCount: 1,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 636992,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%202',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useContentTaxonomyTagsData', () => {
|
||||
it('should return success response', () => {
|
||||
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
|
||||
const contentId = '123';
|
||||
const result = useContentTaxonomyTagsData(contentId);
|
||||
|
||||
expect(result).toEqual({ isSuccess: true, data: 'data' });
|
||||
});
|
||||
|
||||
it('should return failure response', () => {
|
||||
useQuery.mockReturnValueOnce({ isSuccess: false });
|
||||
const contentId = '123';
|
||||
const result = useContentTaxonomyTagsData(contentId);
|
||||
|
||||
expect(result).toEqual({ isSuccess: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useContentData', () => {
|
||||
it('should return success response', () => {
|
||||
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
|
||||
const contentId = '123';
|
||||
const result = useContentData(contentId);
|
||||
|
||||
expect(result).toEqual({ isSuccess: true, data: 'data' });
|
||||
});
|
||||
|
||||
it('should return failure response', () => {
|
||||
useQuery.mockReturnValueOnce({ isSuccess: false });
|
||||
const contentId = '123';
|
||||
const result = useContentData(contentId);
|
||||
|
||||
expect(result).toEqual({ isSuccess: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useContentTaxonomyTagsUpdater', () => {
|
||||
it('should call the update content taxonomy tags function', async () => {
|
||||
useMutation.mockReturnValueOnce({ mutate: jest.fn() });
|
||||
|
||||
const contentId = 'testerContent';
|
||||
const taxonomyId = 123;
|
||||
const mutation = useContentTaxonomyTagsUpdater(contentId, taxonomyId);
|
||||
mutation.mutate({ tags: ['tag1', 'tag2'] });
|
||||
|
||||
expect(useMutation).toBeCalled();
|
||||
|
||||
const [config] = useMutation.mock.calls[0];
|
||||
const { mutationFn } = config;
|
||||
|
||||
await act(async () => {
|
||||
const tags = ['tag1', 'tag2'];
|
||||
await mutationFn({ tags });
|
||||
expect(updateContentTaxonomyTags).toBeCalledWith(contentId, taxonomyId, tags);
|
||||
});
|
||||
});
|
||||
});
|
||||
60
src/content-tags-drawer/data/types.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @typedef {Object} Tag A tag that has been applied to some content.
|
||||
* @property {string} value The value of the tag, also its ID. e.g. "Biology"
|
||||
* @property {string[]} lineage The values of the tag and its parent(s) in the hierarchy
|
||||
* @property {boolean} canChangeObjecttag
|
||||
* @property {boolean} canDeleteObjecttag
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContentTaxonomyTagData A list of the tags from one taxonomy that are applied to a content object.
|
||||
* @property {string} name
|
||||
* @property {number} taxonomyId
|
||||
* @property {boolean} canTagObject
|
||||
* @property {Tag[]} tags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContentTaxonomyTagsData A list of all the tags applied to some content object, grouped by taxonomy.
|
||||
* @property {ContentTaxonomyTagData[]} taxonomies
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContentActions
|
||||
* @property {boolean} deleteable
|
||||
* @property {boolean} draggable
|
||||
* @property {boolean} childAddable
|
||||
* @property {boolean} duplicable
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContentData
|
||||
* @property {string} id
|
||||
* @property {string} displayName
|
||||
* @property {string} category
|
||||
* @property {boolean} hasChildren
|
||||
* @property {string} editedOn
|
||||
* @property {boolean} published
|
||||
* @property {string} publishedOn
|
||||
* @property {string} studioUrl
|
||||
* @property {boolean} releasedToStudents
|
||||
* @property {string|null} releaseDate
|
||||
* @property {string} visibilityState
|
||||
* @property {boolean} hasExplicitStaffLock
|
||||
* @property {string} start
|
||||
* @property {boolean} graded
|
||||
* @property {string} dueDate
|
||||
* @property {string} due
|
||||
* @property {string|null} relativeWeeksDue
|
||||
* @property {string|null} format
|
||||
* @property {boolean} hasChanges
|
||||
* @property {ContentActions} actions
|
||||
* @property {string} explanatoryMessage
|
||||
* @property {string} showCorrectness
|
||||
* @property {boolean} discussionEnabled
|
||||
* @property {boolean} ancestorHasStaffLock
|
||||
* @property {boolean} staffOnlyMessage
|
||||
* @property {boolean} hasPartitionGroupComponents
|
||||
*/
|
||||
2
src/content-tags-drawer/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as ContentTagsDrawer } from './ContentTagsDrawer';
|
||||
38
src/content-tags-drawer/messages.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
headerSubtitle: {
|
||||
id: 'course-authoring.content-tags-drawer.header.subtitle',
|
||||
defaultMessage: 'Manage tags',
|
||||
},
|
||||
addTagsButtonText: {
|
||||
id: 'course-authoring.content-tags-drawer.collapsible.add-tags.button',
|
||||
defaultMessage: 'Add tags',
|
||||
},
|
||||
loadingMessage: {
|
||||
id: 'course-authoring.content-tags-drawer.spinner.loading',
|
||||
defaultMessage: 'Loading',
|
||||
},
|
||||
loadingTagsDropdownMessage: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.spinner.loading',
|
||||
defaultMessage: 'Loading tags',
|
||||
},
|
||||
loadMoreTagsButtonText: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.load-more-tags.button',
|
||||
defaultMessage: 'Load more',
|
||||
},
|
||||
noTagsFoundMessage: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-found',
|
||||
defaultMessage: 'No tags found with the search term "{searchTerm}"',
|
||||
},
|
||||
taxonomyTagsCheckboxAriaLabel: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.selectable-box.aria.label',
|
||||
defaultMessage: '{tag} checkbox',
|
||||
},
|
||||
taxonomyTagsAriaLabel: {
|
||||
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label',
|
||||
defaultMessage: 'taxonomy tags selection',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
2
src/content-tags-drawer/utils.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const extractOrgFromContentId = (contentId) => contentId.split('+')[0].split(':')[1];
|
||||
511
src/course-outline/CourseOutline.jsx
Normal file
@@ -0,0 +1,511 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Layout,
|
||||
Row,
|
||||
TransitionReplace,
|
||||
} from '@edx/paragon';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import {
|
||||
Add as IconAdd,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
} from '@edx/paragon/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { DraggableList } from '@edx/frontend-lib-content-components';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
|
||||
import { LoadingSpinner } from '../generic/Loading';
|
||||
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import ProcessingNotification from '../generic/processing-notification';
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import HeaderNavigations from './header-navigations/HeaderNavigations';
|
||||
import OutlineSideBar from './outline-sidebar/OutlineSidebar';
|
||||
import StatusBar from './status-bar/StatusBar';
|
||||
import EnableHighlightsModal from './enable-highlights-modal/EnableHighlightsModal';
|
||||
import SectionCard from './section-card/SectionCard';
|
||||
import SubsectionCard from './subsection-card/SubsectionCard';
|
||||
import UnitCard from './unit-card/UnitCard';
|
||||
import HighlightsModal from './highlights-modal/HighlightsModal';
|
||||
import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder';
|
||||
import PublishModal from './publish-modal/PublishModal';
|
||||
import ConfigureModal from './configure-modal/ConfigureModal';
|
||||
import DeleteModal from './delete-modal/DeleteModal';
|
||||
import PageAlerts from './page-alerts/PageAlerts';
|
||||
import { useCourseOutline } from './hooks';
|
||||
import messages from './messages';
|
||||
import { useUserPermissions } from '../generic/hooks';
|
||||
import { getUserPermissionsEnabled } from '../generic/data/selectors';
|
||||
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
|
||||
|
||||
const CourseOutline = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
courseName,
|
||||
savingStatus,
|
||||
statusBarData,
|
||||
courseActions,
|
||||
sectionsList,
|
||||
isCustomRelativeDatesActive,
|
||||
isLoading,
|
||||
isReIndexShow,
|
||||
showErrorAlert,
|
||||
showSuccessAlert,
|
||||
isSectionsExpanded,
|
||||
isEnableHighlightsModalOpen,
|
||||
isInternetConnectionAlertFailed,
|
||||
isDisabledReindexButton,
|
||||
isHighlightsModalOpen,
|
||||
isPublishModalOpen,
|
||||
isConfigureModalOpen,
|
||||
isDeleteModalOpen,
|
||||
closeHighlightsModal,
|
||||
closePublishModal,
|
||||
handleConfigureModalClose,
|
||||
closeDeleteModal,
|
||||
openPublishModal,
|
||||
openConfigureModal,
|
||||
openDeleteModal,
|
||||
headerNavigationsActions,
|
||||
openEnableHighlightsModal,
|
||||
closeEnableHighlightsModal,
|
||||
handleEnableHighlightsSubmit,
|
||||
handleInternetConnectionFailed,
|
||||
handleOpenHighlightsModal,
|
||||
handleHighlightsFormSubmit,
|
||||
handleConfigureItemSubmit,
|
||||
handlePublishItemSubmit,
|
||||
handleEditSubmit,
|
||||
handleDeleteItemSubmit,
|
||||
handleDuplicateSectionSubmit,
|
||||
handleDuplicateSubsectionSubmit,
|
||||
handleDuplicateUnitSubmit,
|
||||
handleNewSectionSubmit,
|
||||
handleNewSubsectionSubmit,
|
||||
handleNewUnitSubmit,
|
||||
getUnitUrl,
|
||||
handleSectionDragAndDrop,
|
||||
handleSubsectionDragAndDrop,
|
||||
handleVideoSharingOptionChange,
|
||||
handleUnitDragAndDrop,
|
||||
handleCopyToClipboardClick,
|
||||
handlePasteClipboardClick,
|
||||
notificationDismissUrl,
|
||||
discussionsSettings,
|
||||
discussionsIncontextFeedbackUrl,
|
||||
discussionsIncontextLearnmoreUrl,
|
||||
deprecatedBlocksInfo,
|
||||
proctoringErrors,
|
||||
mfeProctoredExamSettingsUrl,
|
||||
handleDismissNotification,
|
||||
advanceSettingsUrl,
|
||||
} = useCourseOutline({ courseId });
|
||||
|
||||
const [sections, setSections] = useState(sectionsList);
|
||||
|
||||
const { checkPermission } = useUserPermissions();
|
||||
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||
const hasOutlinePermissions = !userPermissionsEnabled || (
|
||||
userPermissionsEnabled && (checkPermission('manage_libraries') || checkPermission('manage_content'))
|
||||
);
|
||||
|
||||
let initialSections = [...sectionsList];
|
||||
|
||||
const {
|
||||
isShow: isShowProcessingNotification,
|
||||
title: processingNotificationTitle,
|
||||
} = useSelector(getProcessingNotification);
|
||||
|
||||
const finalizeSectionOrder = () => (newSections) => {
|
||||
initialSections = [...sectionsList];
|
||||
handleSectionDragAndDrop(newSections.map(section => section.id), () => {
|
||||
setSections(() => initialSections);
|
||||
});
|
||||
};
|
||||
|
||||
const setSubsection = (index) => (updatedSubsection) => {
|
||||
const section = { ...sections[index] };
|
||||
section.childInfo = { ...section.childInfo };
|
||||
section.childInfo.children = updatedSubsection();
|
||||
setSections([...sections.slice(0, index), section, ...sections.slice(index + 1)]);
|
||||
};
|
||||
|
||||
const finalizeSubsectionOrder = (section) => () => (newSubsections) => {
|
||||
initialSections = [...sectionsList];
|
||||
handleSubsectionDragAndDrop(section.id, newSubsections.map(subsection => subsection.id), () => {
|
||||
setSections(() => initialSections);
|
||||
});
|
||||
};
|
||||
|
||||
const setUnit = (sectionIndex, subsectionIndex) => (updatedUnits) => {
|
||||
const section = { ...sections[sectionIndex] };
|
||||
section.childInfo = { ...section.childInfo };
|
||||
|
||||
const subsection = { ...section.childInfo.children[subsectionIndex] };
|
||||
subsection.childInfo = { ...subsection.childInfo };
|
||||
subsection.childInfo.children = updatedUnits();
|
||||
|
||||
const updatedSubsections = [...section.childInfo.children];
|
||||
updatedSubsections[subsectionIndex] = subsection;
|
||||
section.childInfo.children = updatedSubsections;
|
||||
setSections([...sections.slice(0, sectionIndex), section, ...sections.slice(sectionIndex + 1)]);
|
||||
};
|
||||
|
||||
const finalizeUnitOrder = (section, subsection) => () => (newUnits) => {
|
||||
initialSections = [...sectionsList];
|
||||
handleUnitDragAndDrop(section.id, subsection.id, newUnits.map(unit => unit.id), () => {
|
||||
setSections(() => initialSections);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if item can be moved by given step.
|
||||
* Inner function returns false if the new index after moving by given step
|
||||
* is out of bounds of item length.
|
||||
* If it is within bounds, returns draggable flag of the item in the new index.
|
||||
* This helps us avoid moving the item to a position of unmovable item.
|
||||
* @param {Array} items
|
||||
* @returns {(id, step) => bool}
|
||||
*/
|
||||
const canMoveItem = (items) => (id, step) => {
|
||||
const newId = id + step;
|
||||
const indexCheck = newId >= 0 && newId < items.length;
|
||||
if (!indexCheck) {
|
||||
return false;
|
||||
}
|
||||
const newItem = items[newId];
|
||||
return newItem.actions.draggable;
|
||||
};
|
||||
|
||||
/**
|
||||
* Move section to new index
|
||||
* @param {any} currentIndex
|
||||
* @param {any} newIndex
|
||||
*/
|
||||
const updateSectionOrderByIndex = (currentIndex, newIndex) => {
|
||||
if (currentIndex === newIndex) {
|
||||
return;
|
||||
}
|
||||
setSections((prevSections) => {
|
||||
const newSections = arrayMove(prevSections, currentIndex, newIndex);
|
||||
finalizeSectionOrder()(newSections);
|
||||
return newSections;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a function for given section which can move a subsection inside it
|
||||
* to a new position
|
||||
* @param {any} sectionIndex
|
||||
* @param {any} section
|
||||
* @param {any} subsections
|
||||
* @returns {(currentIndex, newIndex) => void}
|
||||
*/
|
||||
const updateSubsectionOrderByIndex = (sectionIndex, section, subsections) => (currentIndex, newIndex) => {
|
||||
if (currentIndex === newIndex) {
|
||||
return;
|
||||
}
|
||||
setSubsection(sectionIndex)(() => {
|
||||
const newSubsections = arrayMove(subsections, currentIndex, newIndex);
|
||||
finalizeSubsectionOrder(section)()(newSubsections);
|
||||
return newSubsections;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a function for given section & subsection which can move a unit
|
||||
* inside it to a new position
|
||||
* @param {any} sectionIndex
|
||||
* @param {any} section
|
||||
* @param {any} subsection
|
||||
* @param {any} units
|
||||
* @returns {(currentIndex, newIndex) => void}
|
||||
*/
|
||||
const updateUnitOrderByIndex = (
|
||||
sectionIndex,
|
||||
subsectionIndex,
|
||||
section,
|
||||
subsection,
|
||||
units,
|
||||
) => (currentIndex, newIndex) => {
|
||||
if (currentIndex === newIndex) {
|
||||
return;
|
||||
}
|
||||
setUnit(sectionIndex, subsectionIndex)(() => {
|
||||
const newUnits = arrayMove(units, currentIndex, newIndex);
|
||||
finalizeUnitOrder(section, subsection)()(newUnits);
|
||||
return newUnits;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSections(sectionsList);
|
||||
}, [sectionsList]);
|
||||
|
||||
if (!hasOutlinePermissions) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return (
|
||||
<Row className="m-0 mt-4 justify-content-center">
|
||||
<LoadingSpinner />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))}</title>
|
||||
</Helmet>
|
||||
<Container size="xl" className="px-4">
|
||||
<section className="course-outline-container mb-4 mt-5">
|
||||
<PageAlerts
|
||||
notificationDismissUrl={notificationDismissUrl}
|
||||
handleDismissNotification={handleDismissNotification}
|
||||
discussionsSettings={discussionsSettings}
|
||||
discussionsIncontextFeedbackUrl={discussionsIncontextFeedbackUrl}
|
||||
discussionsIncontextLearnmoreUrl={discussionsIncontextLearnmoreUrl}
|
||||
deprecatedBlocksInfo={deprecatedBlocksInfo}
|
||||
proctoringErrors={proctoringErrors}
|
||||
mfeProctoredExamSettingsUrl={mfeProctoredExamSettingsUrl}
|
||||
advanceSettingsUrl={advanceSettingsUrl}
|
||||
savingStatus={savingStatus}
|
||||
/>
|
||||
<TransitionReplace>
|
||||
{showSuccessAlert ? (
|
||||
<AlertMessage
|
||||
key={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
|
||||
show={showSuccessAlert}
|
||||
variant="success"
|
||||
icon={CheckCircleIcon}
|
||||
title={intl.formatMessage(messages.alertSuccessTitle)}
|
||||
description={intl.formatMessage(messages.alertSuccessDescription)}
|
||||
aria-hidden="true"
|
||||
aria-labelledby={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
|
||||
aria-describedby={intl.formatMessage(messages.alertSuccessAriaDescribedby)}
|
||||
/>
|
||||
) : null}
|
||||
</TransitionReplace>
|
||||
<SubHeader
|
||||
className="mt-5"
|
||||
title={intl.formatMessage(messages.headingTitle)}
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
headerActions={(
|
||||
<HeaderNavigations
|
||||
isReIndexShow={isReIndexShow}
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
headerNavigationsActions={headerNavigationsActions}
|
||||
isDisabledReindexButton={isDisabledReindexButton}
|
||||
hasSections={Boolean(sectionsList.length)}
|
||||
courseActions={courseActions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Layout
|
||||
lg={[{ span: 9 }, { span: 3 }]}
|
||||
md={[{ span: 9 }, { span: 3 }]}
|
||||
sm={[{ span: 12 }, { span: 12 }]}
|
||||
xs={[{ span: 12 }, { span: 12 }]}
|
||||
xl={[{ span: 9 }, { span: 3 }]}
|
||||
>
|
||||
<Layout.Element>
|
||||
<article>
|
||||
<div>
|
||||
<section className="course-outline-section">
|
||||
<StatusBar
|
||||
courseId={courseId}
|
||||
isLoading={isLoading}
|
||||
statusBarData={statusBarData}
|
||||
openEnableHighlightsModal={openEnableHighlightsModal}
|
||||
handleVideoSharingOptionChange={handleVideoSharingOptionChange}
|
||||
/>
|
||||
<div className="pt-4">
|
||||
{sections.length ? (
|
||||
<>
|
||||
<DraggableList itemList={sections} setState={setSections} updateOrder={finalizeSectionOrder}>
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<SectionCard
|
||||
id={section.id}
|
||||
key={section.id}
|
||||
section={section}
|
||||
index={sectionIndex}
|
||||
canMoveItem={canMoveItem(sections)}
|
||||
isSelfPaced={statusBarData.isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
savingStatus={savingStatus}
|
||||
onOpenHighlightsModal={handleOpenHighlightsModal}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSectionSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSectionSubmit}
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
onNewSubsectionSubmit={handleNewSubsectionSubmit}
|
||||
onOrderChange={updateSectionOrderByIndex}
|
||||
>
|
||||
<DraggableList
|
||||
itemList={section.childInfo.children}
|
||||
setState={setSubsection(sectionIndex)}
|
||||
updateOrder={finalizeSubsectionOrder(section)}
|
||||
>
|
||||
{section.childInfo.children.map((subsection, subsectionIndex) => (
|
||||
<SubsectionCard
|
||||
key={subsection.id}
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
index={subsectionIndex}
|
||||
canMoveItem={canMoveItem(section.childInfo.children)}
|
||||
isSelfPaced={statusBarData.isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
savingStatus={savingStatus}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onNewUnitSubmit={handleNewUnitSubmit}
|
||||
onOrderChange={updateSubsectionOrderByIndex(
|
||||
sectionIndex,
|
||||
section,
|
||||
section.childInfo.children,
|
||||
)}
|
||||
onPasteClick={handlePasteClipboardClick}
|
||||
>
|
||||
<DraggableList
|
||||
itemList={subsection.childInfo.children}
|
||||
setState={setUnit(sectionIndex, subsectionIndex)}
|
||||
updateOrder={finalizeUnitOrder(section, subsection)}
|
||||
>
|
||||
{subsection.childInfo.children.map((unit, unitIndex) => (
|
||||
<UnitCard
|
||||
key={unit.id}
|
||||
unit={unit}
|
||||
subsection={subsection}
|
||||
section={section}
|
||||
isSelfPaced={statusBarData.isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
index={unitIndex}
|
||||
canMoveItem={canMoveItem(subsection.childInfo.children)}
|
||||
savingStatus={savingStatus}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateUnitSubmit}
|
||||
getTitleLink={getUnitUrl}
|
||||
onOrderChange={updateUnitOrderByIndex(
|
||||
sectionIndex,
|
||||
subsectionIndex,
|
||||
section,
|
||||
subsection,
|
||||
subsection.childInfo.children,
|
||||
)}
|
||||
onCopyToClipboardClick={handleCopyToClipboardClick}
|
||||
discussionsSettings={discussionsSettings}
|
||||
/>
|
||||
))}
|
||||
</DraggableList>
|
||||
</SubsectionCard>
|
||||
))}
|
||||
</DraggableList>
|
||||
</SectionCard>
|
||||
))}
|
||||
</DraggableList>
|
||||
{courseActions.childAddable && (
|
||||
<Button
|
||||
data-testid="new-section-button"
|
||||
className="mt-4"
|
||||
variant="outline-primary"
|
||||
onClick={handleNewSectionSubmit}
|
||||
iconBefore={IconAdd}
|
||||
block
|
||||
>
|
||||
{intl.formatMessage(messages.newSectionButton)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyPlaceholder
|
||||
onCreateNewSection={handleNewSectionSubmit}
|
||||
childAddable={courseActions.childAddable}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</Layout.Element>
|
||||
<Layout.Element>
|
||||
<OutlineSideBar courseId={courseId} />
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
<EnableHighlightsModal
|
||||
isOpen={isEnableHighlightsModalOpen}
|
||||
close={closeEnableHighlightsModal}
|
||||
onEnableHighlightsSubmit={handleEnableHighlightsSubmit}
|
||||
/>
|
||||
</section>
|
||||
<HighlightsModal
|
||||
isOpen={isHighlightsModalOpen}
|
||||
onClose={closeHighlightsModal}
|
||||
onSubmit={handleHighlightsFormSubmit}
|
||||
/>
|
||||
<PublishModal
|
||||
isOpen={isPublishModalOpen}
|
||||
onClose={closePublishModal}
|
||||
onPublishSubmit={handlePublishItemSubmit}
|
||||
/>
|
||||
<ConfigureModal
|
||||
isOpen={isConfigureModalOpen}
|
||||
onClose={handleConfigureModalClose}
|
||||
onConfigureSubmit={handleConfigureItemSubmit}
|
||||
/>
|
||||
<DeleteModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
close={closeDeleteModal}
|
||||
onDeleteSubmit={handleDeleteItemSubmit}
|
||||
/>
|
||||
</Container>
|
||||
<div className="alert-toast">
|
||||
<ProcessingNotification
|
||||
isShow={isShowProcessingNotification}
|
||||
title={processingNotificationTitle}
|
||||
/>
|
||||
<InternetConnectionAlert
|
||||
isFailed={isInternetConnectionAlertFailed}
|
||||
isQueryPending={savingStatus === RequestStatus.PENDING}
|
||||
onInternetConnectionFailed={handleInternetConnectionFailed}
|
||||
/>
|
||||
{showErrorAlert && (
|
||||
<AlertMessage
|
||||
key={intl.formatMessage(messages.alertErrorTitle)}
|
||||
show={showErrorAlert}
|
||||
variant="danger"
|
||||
icon={WarningIcon}
|
||||
title={intl.formatMessage(messages.alertErrorTitle)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CourseOutline.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CourseOutline;
|
||||
13
src/course-outline/CourseOutline.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
@import "./header-navigations/HeaderNavigations";
|
||||
@import "./status-bar/StatusBar";
|
||||
@import "./section-card/SectionCard";
|
||||
@import "./subsection-card/SubsectionCard";
|
||||
@import "./unit-card/UnitCard";
|
||||
@import "./card-header/CardHeader";
|
||||
@import "./empty-placeholder/EmptyPlaceholder";
|
||||
@import "./highlights-modal/HighlightsModal";
|
||||
@import "./publish-modal/PublishModal";
|
||||
@import "./configure-modal/ConfigureModal";
|
||||
@import "./drag-helper/ConditionalSortableElement";
|
||||
@import "./xblock-status/XBlockStatus";
|
||||
@import "./paste-button/PasteButton";
|
||||
1898
src/course-outline/CourseOutline.test.jsx
Normal file
@@ -0,0 +1,1898 @@
|
||||
import {
|
||||
act, render, waitFor, fireEvent, within,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import {
|
||||
getCourseBestPracticesApiUrl,
|
||||
getCourseLaunchApiUrl,
|
||||
getCourseOutlineIndexApiUrl,
|
||||
getCourseReindexApiUrl,
|
||||
getXBlockApiUrl,
|
||||
getCourseBlockApiUrl,
|
||||
getCourseItemApiUrl,
|
||||
getXBlockBaseApiUrl,
|
||||
getClipboardUrl,
|
||||
} from './data/api';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import {
|
||||
fetchCourseBestPracticesQuery,
|
||||
fetchCourseLaunchQuery,
|
||||
fetchCourseOutlineIndexQuery,
|
||||
updateCourseSectionHighlightsQuery,
|
||||
} from './data/thunk';
|
||||
import initializeStore from '../store';
|
||||
import {
|
||||
courseOutlineIndexMock,
|
||||
courseOutlineIndexWithoutSections,
|
||||
courseBestPracticesMock,
|
||||
courseLaunchMock,
|
||||
courseSectionMock,
|
||||
courseSubsectionMock,
|
||||
} from './__mocks__';
|
||||
import { executeThunk } from '../utils';
|
||||
import { COURSE_BLOCK_NAMES, VIDEO_SHARING_OPTIONS } from './constants';
|
||||
import CourseOutline from './CourseOutline';
|
||||
import messages from './messages';
|
||||
import headerMessages from './header-navigations/messages';
|
||||
import cardHeaderMessages from './card-header/messages';
|
||||
import enableHighlightsModalMessages from './enable-highlights-modal/messages';
|
||||
import statusBarMessages from './status-bar/messages';
|
||||
import configureModalMessages from './configure-modal/messages';
|
||||
import pasteButtonMessages from './paste-button/messages';
|
||||
import subsectionMessages from './subsection-card/messages';
|
||||
import pageAlertMessages from './page-alerts/messages';
|
||||
|
||||
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
const userId = 3;
|
||||
const userPermissionsData = { permissions: [] };
|
||||
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
pathname: mockPathname,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../help-urls/hooks', () => ({
|
||||
useHelpUrls: () => ({
|
||||
contentHighlights: 'some',
|
||||
visibility: 'some',
|
||||
grading: 'some',
|
||||
outline: 'some',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform/i18n'),
|
||||
useIntl: () => ({
|
||||
formatMessage: (message) => message.defaultMessage,
|
||||
}),
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseOutline courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<CourseOutline />', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(200, courseOutlineIndexMock);
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsEnabledFlagUrl)
|
||||
.reply(200, { enabled: false });
|
||||
axiosMock
|
||||
.onGet(getUserPermissionsUrl(courseId, userId))
|
||||
.reply(200, userPermissionsData);
|
||||
executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
await executeThunk(fetchCourseOutlineIndexQuery(courseId), store.dispatch);
|
||||
});
|
||||
|
||||
it('render CourseOutline component correctly', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render permissionDenied if incorrect permissions', async () => {
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
expect(getByTestId('permissionDeniedAlert')).toBeVisible();
|
||||
});
|
||||
|
||||
it('check reindex and render success alert is correctly', async () => {
|
||||
const { findByText, findByTestId } = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink))
|
||||
.reply(200);
|
||||
const reindexButton = await findByTestId('course-reindex');
|
||||
fireEvent.click(reindexButton);
|
||||
|
||||
expect(await findByText(messages.alertSuccessDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check video sharing option udpates correctly', async () => {
|
||||
const { findByLabelText } = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseBlockApiUrl(courseId), {
|
||||
metadata: {
|
||||
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
|
||||
},
|
||||
})
|
||||
.reply(200);
|
||||
const optionDropdown = await findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage);
|
||||
await act(
|
||||
async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }),
|
||||
);
|
||||
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
|
||||
metadata: {
|
||||
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('check video sharing option shows error on failure', async () => {
|
||||
const { findByLabelText, queryByRole } = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseBlockApiUrl(courseId), {
|
||||
metadata: {
|
||||
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
|
||||
},
|
||||
})
|
||||
.reply(500);
|
||||
const optionDropdown = await findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage);
|
||||
await act(
|
||||
async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }),
|
||||
);
|
||||
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
|
||||
metadata: {
|
||||
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
|
||||
},
|
||||
}));
|
||||
|
||||
const alertElement = queryByRole('alert');
|
||||
expect(alertElement).toHaveTextContent(
|
||||
pageAlertMessages.alertFailedGeneric.defaultMessage,
|
||||
);
|
||||
});
|
||||
|
||||
it('render error alert after failed reindex correctly', async () => {
|
||||
const { findByText, findByTestId } = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink))
|
||||
.reply(500);
|
||||
const reindexButton = await findByTestId('course-reindex');
|
||||
await act(async () => fireEvent.click(reindexButton));
|
||||
|
||||
expect(await findByText(messages.alertErrorTitle.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds new section correctly', async () => {
|
||||
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
|
||||
let elements = await findAllByTestId('section-card');
|
||||
window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||
top: 0,
|
||||
bottom: 4000,
|
||||
}));
|
||||
expect(elements.length).toBe(4);
|
||||
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: courseSectionMock.id,
|
||||
});
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(courseSectionMock.id))
|
||||
.reply(200, courseSectionMock);
|
||||
const newSectionButton = await findByTestId('new-section-button');
|
||||
await act(async () => fireEvent.click(newSectionButton));
|
||||
|
||||
elements = await findAllByTestId('section-card');
|
||||
expect(elements.length).toBe(5);
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
|
||||
});
|
||||
|
||||
it('adds new subsection correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const [section] = await findAllByTestId('section-card');
|
||||
let subsections = await within(section).findAllByTestId('subsection-card');
|
||||
expect(subsections.length).toBe(2);
|
||||
window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||
top: 0,
|
||||
bottom: 4000,
|
||||
}));
|
||||
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: courseSubsectionMock.id,
|
||||
});
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(courseSubsectionMock.id))
|
||||
.reply(200, courseSubsectionMock);
|
||||
const newSubsectionButton = await within(section).findByTestId('new-subsection-button');
|
||||
await act(async () => {
|
||||
fireEvent.click(newSubsectionButton);
|
||||
});
|
||||
|
||||
subsections = await within(section).findAllByTestId('subsection-card');
|
||||
expect(subsections.length).toBe(3);
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
|
||||
});
|
||||
|
||||
it('adds new unit correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandBtn);
|
||||
const units = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
expect(units.length).toBe(1);
|
||||
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: 'some',
|
||||
});
|
||||
const newUnitButton = await within(subsectionElement).findByTestId('new-unit-button');
|
||||
await act(async () => fireEvent.click(newUnitButton));
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [subsection] = section.childInfo.children;
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
|
||||
parent_locator: subsection.id,
|
||||
category: COURSE_BLOCK_NAMES.vertical.id,
|
||||
display_name: COURSE_BLOCK_NAMES.vertical.name,
|
||||
}));
|
||||
});
|
||||
|
||||
it('render checklist value correctly', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseBestPracticesApiUrl({
|
||||
courseId, excludeGraded: true, all: true,
|
||||
}))
|
||||
.reply(200, courseBestPracticesMock);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseLaunchApiUrl({
|
||||
courseId, gradedOnly: true, validateOras: true, all: true,
|
||||
}))
|
||||
.reply(200, courseLaunchMock);
|
||||
|
||||
await executeThunk(fetchCourseLaunchQuery({
|
||||
courseId, gradedOnly: true, validateOras: true, all: true,
|
||||
}), store.dispatch);
|
||||
await executeThunk(fetchCourseBestPracticesQuery({
|
||||
courseId, excludeGraded: true, all: true,
|
||||
}), store.dispatch);
|
||||
|
||||
expect(getByText('4/9 completed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check highlights are enabled after enable highlights query is successful', async () => {
|
||||
const { findByTestId, findByText } = render(<RootWrapper />);
|
||||
|
||||
axiosMock.reset();
|
||||
axiosMock
|
||||
.onPost(getCourseBlockApiUrl(courseId), {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
highlights_enabled_for_messaging: true,
|
||||
},
|
||||
})
|
||||
.reply(200);
|
||||
axiosMock
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...courseOutlineIndexMock,
|
||||
courseStructure: {
|
||||
...courseOutlineIndexMock.courseStructure,
|
||||
highlightsEnabledForMessaging: true,
|
||||
},
|
||||
});
|
||||
|
||||
const enableButton = await findByTestId('highlights-enable-button');
|
||||
fireEvent.click(enableButton);
|
||||
const saveButton = await findByText(enableHighlightsModalMessages.submitButton.defaultMessage);
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
expect(await findByTestId('highlights-enabled-span')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should expand and collapse subsections, after click on subheader buttons', async () => {
|
||||
const { queryAllByTestId, findByText } = render(<RootWrapper />);
|
||||
|
||||
const collapseBtn = await findByText(headerMessages.collapseAllButton.defaultMessage);
|
||||
expect(collapseBtn).toBeInTheDocument();
|
||||
fireEvent.click(collapseBtn);
|
||||
|
||||
const expandBtn = await findByText(headerMessages.expandAllButton.defaultMessage);
|
||||
expect(expandBtn).toBeInTheDocument();
|
||||
fireEvent.click(expandBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
const cardSubsections = queryAllByTestId('section-card__subsections');
|
||||
cardSubsections.forEach(element => expect(element).toBeVisible());
|
||||
|
||||
fireEvent.click(collapseBtn);
|
||||
cardSubsections.forEach(element => expect(element).not.toBeVisible());
|
||||
});
|
||||
});
|
||||
|
||||
it('render CourseOutline component without sections correctly', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(200, courseOutlineIndexWithoutSections);
|
||||
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('empty-placeholder')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('render configuration alerts and check dismiss query', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...courseOutlineIndexMock,
|
||||
notificationDismissUrl: '/some/url',
|
||||
});
|
||||
|
||||
const { findByRole } = render(<RootWrapper />);
|
||||
expect(await findByRole('alert')).toBeInTheDocument();
|
||||
const dismissBtn = await findByRole('button', { name: 'Dismiss' });
|
||||
axiosMock
|
||||
.onDelete('/some/url')
|
||||
.reply(204);
|
||||
fireEvent.click(dismissBtn);
|
||||
|
||||
expect(axiosMock.history.delete.length).toBe(1);
|
||||
});
|
||||
|
||||
it('check edit title works for section, subsection and unit', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const checkEditTitle = async (section, element, item, newName, elementName) => {
|
||||
axiosMock.reset();
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(item.id, {
|
||||
metadata: {
|
||||
display_name: newName,
|
||||
},
|
||||
}))
|
||||
.reply(200, { dummy: 'value' });
|
||||
// mock section, subsection and unit name and check within the elements.
|
||||
// this is done to avoid adding conditions to this mock.
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, {
|
||||
...section,
|
||||
display_name: newName,
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0],
|
||||
display_name: newName,
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0].childInfo.children[0],
|
||||
display_name: newName,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const editButton = await within(element).findByTestId(`${elementName}-edit-button`);
|
||||
fireEvent.click(editButton);
|
||||
const editField = await within(element).findByTestId(`${elementName}-edit-field`);
|
||||
fireEvent.change(editField, { target: { value: newName } });
|
||||
await act(async () => fireEvent.blur(editField));
|
||||
expect(
|
||||
axiosMock.history.post[axiosMock.history.post.length - 1].data,
|
||||
`Failed for ${elementName}!`,
|
||||
).toBe(JSON.stringify({
|
||||
metadata: {
|
||||
display_name: newName,
|
||||
},
|
||||
}));
|
||||
const results = await within(element).findAllByText(newName);
|
||||
expect(results.length, `Failed for ${elementName}!`).toBeGreaterThan(0);
|
||||
};
|
||||
|
||||
// check section
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
await checkEditTitle(section, sectionElement, section, 'New section name', 'section');
|
||||
|
||||
// check subsection
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
await checkEditTitle(section, subsectionElement, subsection, 'New subsection name', 'subsection');
|
||||
|
||||
// check unit
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandBtn);
|
||||
const [unit] = subsection.childInfo.children;
|
||||
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
await checkEditTitle(section, unitElement, unit, 'New unit name', 'unit');
|
||||
});
|
||||
|
||||
it('check whether section, subsection and unit is deleted when corresponding delete button is clicked', async () => {
|
||||
const { findAllByTestId, findByTestId, queryByText } = render(<RootWrapper />);
|
||||
// get section, subsection and unit
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandBtn);
|
||||
const [unit] = subsection.childInfo.children;
|
||||
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
const checkDeleteBtn = async (item, element, elementName) => {
|
||||
await waitFor(() => {
|
||||
expect(queryByText(item.displayName), `Failed for ${elementName}!`).toBeInTheDocument();
|
||||
});
|
||||
|
||||
axiosMock.onDelete(getCourseItemApiUrl(item.id)).reply(200);
|
||||
|
||||
const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`);
|
||||
fireEvent.click(menu);
|
||||
const deleteButton = await within(element).findByTestId(`${elementName}-card-header__menu-delete-button`);
|
||||
fireEvent.click(deleteButton);
|
||||
const confirmButton = await findByTestId('delete-confirm-button');
|
||||
await act(async () => fireEvent.click(confirmButton));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText(item.displayName), `Failed for ${elementName}!`).not.toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
// delete unit, subsection and then section in order.
|
||||
// check unit
|
||||
await checkDeleteBtn(unit, unitElement, 'unit');
|
||||
// check subsection
|
||||
await checkDeleteBtn(subsection, subsectionElement, 'subsection');
|
||||
// check section
|
||||
await checkDeleteBtn(section, sectionElement, 'section');
|
||||
});
|
||||
|
||||
it('check whether section, subsection and unit is duplicated successfully', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// get section, subsection and unit
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandBtn);
|
||||
const [unit] = subsection.childInfo.children;
|
||||
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
const checkDuplicateBtn = async (item, parentElement, element, elementName, expectedLength) => {
|
||||
// baseline
|
||||
if (parentElement) {
|
||||
expect(
|
||||
await within(parentElement).findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength - 1);
|
||||
} else {
|
||||
expect(
|
||||
await findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength - 1);
|
||||
}
|
||||
|
||||
const duplicatedItemId = item.id + elementName;
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: duplicatedItemId,
|
||||
});
|
||||
if (elementName === 'section') {
|
||||
section.id = duplicatedItemId;
|
||||
} else if (elementName === 'subsection') {
|
||||
section.childInfo.children = [...section.childInfo.children, { ...subsection, id: duplicatedItemId }];
|
||||
} else if (elementName === 'unit') {
|
||||
subsection.childInfo.children = [...subsection.childInfo.children, { ...unit, id: duplicatedItemId }];
|
||||
section.childInfo.children = [subsection, ...section.childInfo.children.slice(1)];
|
||||
}
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, {
|
||||
...section,
|
||||
});
|
||||
|
||||
const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`);
|
||||
fireEvent.click(menu);
|
||||
const duplicateButton = await within(element).findByTestId(`${elementName}-card-header__menu-duplicate-button`);
|
||||
await act(async () => fireEvent.click(duplicateButton));
|
||||
if (parentElement) {
|
||||
expect(
|
||||
await within(parentElement).findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength);
|
||||
} else {
|
||||
expect(
|
||||
await findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength);
|
||||
}
|
||||
};
|
||||
|
||||
// duplicate unit, subsection and then section in order.
|
||||
// check unit
|
||||
await checkDuplicateBtn(unit, subsectionElement, unitElement, 'unit', 2);
|
||||
// check subsection
|
||||
await checkDuplicateBtn(subsection, sectionElement, subsectionElement, 'subsection', 3);
|
||||
// check section
|
||||
await checkDuplicateBtn(section, null, sectionElement, 'section', 5);
|
||||
});
|
||||
|
||||
it('check section, subsection & unit is published when publish button is clicked', async () => {
|
||||
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandBtn);
|
||||
const [unit] = subsection.childInfo.children;
|
||||
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
const checkPublishBtn = async (item, element, elementName) => {
|
||||
expect(
|
||||
(await within(element).getAllByRole('status'))[0],
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveTextContent(cardHeaderMessages.statusBadgeDraft.defaultMessage);
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(item.id), {
|
||||
publish: 'make_public',
|
||||
})
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
let mockReturnValue = {
|
||||
...section,
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0],
|
||||
published: true,
|
||||
},
|
||||
...section.childInfo.children.slice(1),
|
||||
],
|
||||
},
|
||||
};
|
||||
if (elementName === 'unit') {
|
||||
mockReturnValue = {
|
||||
...section,
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0],
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0].childInfo.children[0],
|
||||
published: true,
|
||||
},
|
||||
...section.childInfo.children[0].childInfo.children.slice(1),
|
||||
],
|
||||
},
|
||||
},
|
||||
...section.childInfo.children.slice(1),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, mockReturnValue);
|
||||
|
||||
const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`);
|
||||
fireEvent.click(menu);
|
||||
const publishButton = await within(element).findByTestId(`${elementName}-card-header__menu-publish-button`);
|
||||
await act(async () => fireEvent.click(publishButton));
|
||||
const confirmButton = await findByTestId('publish-confirm-button');
|
||||
await act(async () => fireEvent.click(confirmButton));
|
||||
|
||||
expect(
|
||||
(await within(element).getAllByRole('status'))[0],
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveTextContent(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage);
|
||||
};
|
||||
|
||||
// publish unit, subsection and then section in order.
|
||||
// check unit
|
||||
await checkPublishBtn(unit, unitElement, 'unit');
|
||||
// check subsection
|
||||
await checkPublishBtn(subsection, subsectionElement, 'subsection');
|
||||
// section doesn't display badges
|
||||
});
|
||||
|
||||
it('check configure modal for section', async () => {
|
||||
const { findByTestId, findAllByTestId } = render(<RootWrapper />);
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const newReleaseDateIso = '2025-09-10T22:00:00Z';
|
||||
const newReleaseDate = '09/10/2025';
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(section.id), {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
visible_to_staff_only: true,
|
||||
start: newReleaseDateIso,
|
||||
},
|
||||
})
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, {
|
||||
...section,
|
||||
start: newReleaseDateIso,
|
||||
});
|
||||
|
||||
const [firstSection] = await findAllByTestId('section-card');
|
||||
|
||||
const sectionDropdownButton = await within(firstSection).findByTestId('section-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(sectionDropdownButton));
|
||||
const configureBtn = await within(firstSection).findByTestId('section-card-header__menu-configure-button');
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
let releaseDateStack = await findByTestId('release-date-stack');
|
||||
let releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||
expect(releaseDatePicker).toHaveValue('08/10/2023');
|
||||
|
||||
await act(async () => fireEvent.change(releaseDatePicker, { target: { value: newReleaseDate } }));
|
||||
expect(releaseDatePicker).toHaveValue(newReleaseDate);
|
||||
const saveButton = await findByTestId('configure-save-button');
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
visible_to_staff_only: true,
|
||||
start: newReleaseDateIso,
|
||||
},
|
||||
}));
|
||||
|
||||
await act(async () => fireEvent.click(sectionDropdownButton));
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
releaseDateStack = await findByTestId('release-date-stack');
|
||||
releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||
expect(releaseDatePicker).toHaveValue(newReleaseDate);
|
||||
});
|
||||
|
||||
it('check configure modal for subsection', async () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||
const [subsection] = section.childInfo.children;
|
||||
const expectedRequestData = {
|
||||
publish: 'republish',
|
||||
graderType: 'Homework',
|
||||
isPrereq: false,
|
||||
prereqMinScore: 100,
|
||||
prereqMinCompletion: 100,
|
||||
metadata: {
|
||||
visible_to_staff_only: null,
|
||||
due: '2025-09-10T05:00:00Z',
|
||||
hide_after_due: true,
|
||||
show_correctness: 'always',
|
||||
is_practice_exam: false,
|
||||
is_time_limited: true,
|
||||
is_proctored_enabled: false,
|
||||
exam_review_rules: '',
|
||||
default_time_limit_minutes: 3270,
|
||||
is_onboarding_exam: false,
|
||||
start: '2025-08-10T00:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(subsection.id), expectedRequestData)
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
const [currentSection] = await findAllByTestId('section-card');
|
||||
const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card');
|
||||
const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button');
|
||||
|
||||
subsection.start = expectedRequestData.metadata.start;
|
||||
subsection.due = expectedRequestData.metadata.due;
|
||||
subsection.format = expectedRequestData.graderType;
|
||||
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
|
||||
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
|
||||
subsection.hideAfterDue = expectedRequestData.metadata.hideAfterDue;
|
||||
section.childInfo.children[0] = subsection;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
|
||||
fireEvent.click(subsectionDropdownButton);
|
||||
const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button');
|
||||
fireEvent.click(configureBtn);
|
||||
|
||||
// update fields
|
||||
let configureModal = await findByTestId('configure-modal');
|
||||
expect(await within(configureModal).findByText(expectedRequestData.graderType)).toBeInTheDocument();
|
||||
let releaseDateStack = await within(configureModal).findByTestId('release-date-stack');
|
||||
let releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||
fireEvent.change(releaseDatePicker, { target: { value: '08/10/2025' } });
|
||||
let releaseDateTimePicker = await within(releaseDateStack).findByPlaceholderText('HH:MM');
|
||||
fireEvent.change(releaseDateTimePicker, { target: { value: '00:00' } });
|
||||
let dueDateStack = await within(configureModal).findByTestId('due-date-stack');
|
||||
let dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||
fireEvent.change(dueDatePicker, { target: { value: '09/10/2025' } });
|
||||
let dueDateTimePicker = await within(dueDateStack).findByPlaceholderText('HH:MM');
|
||||
fireEvent.change(dueDateTimePicker, { target: { value: '05:00' } });
|
||||
let graderTypeDropdown = await within(configureModal).findByTestId('grader-type-select');
|
||||
fireEvent.change(graderTypeDropdown, { target: { value: expectedRequestData.graderType } });
|
||||
|
||||
// visibility tab
|
||||
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
|
||||
fireEvent.click(visibilityTab);
|
||||
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(visibilityRadioButtons[1]);
|
||||
|
||||
let advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||
fireEvent.click(advancedTab);
|
||||
let radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(radioButtons[1]);
|
||||
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||
fireEvent.change(hours, { target: { value: '54:30' } });
|
||||
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
|
||||
// verify request
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
|
||||
configureModal = await findByTestId('configure-modal');
|
||||
releaseDateStack = await within(configureModal).findByTestId('release-date-stack');
|
||||
releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||
expect(releaseDatePicker).toHaveValue('08/10/2025');
|
||||
releaseDateTimePicker = await within(releaseDateStack).findByPlaceholderText('HH:MM');
|
||||
expect(releaseDateTimePicker).toHaveValue('00:00');
|
||||
dueDateStack = await await within(configureModal).findByTestId('due-date-stack');
|
||||
dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||
expect(dueDatePicker).toHaveValue('09/10/2025');
|
||||
dueDateTimePicker = await within(dueDateStack).findByPlaceholderText('HH:MM');
|
||||
expect(dueDateTimePicker).toHaveValue('05:00');
|
||||
graderTypeDropdown = await within(configureModal).findByTestId('grader-type-select');
|
||||
expect(graderTypeDropdown).toHaveValue(expectedRequestData.graderType);
|
||||
|
||||
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||
fireEvent.click(advancedTab);
|
||||
radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
expect(radioButtons[0]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[1]).toHaveProperty('checked', true);
|
||||
hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||
hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||
expect(hours).toHaveValue('54:30');
|
||||
});
|
||||
|
||||
it('check prereq and proctoring settings in configure modal for subsection', async () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||
const [subsection, secondSubsection] = section.childInfo.children;
|
||||
const expectedRequestData = {
|
||||
publish: 'republish',
|
||||
graderType: 'notgraded',
|
||||
isPrereq: true,
|
||||
prereqUsageKey: secondSubsection.id,
|
||||
prereqMinScore: 80,
|
||||
prereqMinCompletion: 90,
|
||||
metadata: {
|
||||
visible_to_staff_only: true,
|
||||
due: '',
|
||||
hide_after_due: false,
|
||||
show_correctness: 'always',
|
||||
is_practice_exam: false,
|
||||
is_time_limited: true,
|
||||
is_proctored_enabled: true,
|
||||
exam_review_rules: 'some rules for proctored exams',
|
||||
default_time_limit_minutes: 30,
|
||||
is_onboarding_exam: false,
|
||||
start: '1970-01-01T05:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(subsection.id), expectedRequestData)
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
const [currentSection] = await findAllByTestId('section-card');
|
||||
const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card');
|
||||
const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button');
|
||||
|
||||
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
|
||||
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
|
||||
subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled;
|
||||
subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam;
|
||||
subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam;
|
||||
subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules;
|
||||
subsection.isPrereq = expectedRequestData.isPrereq;
|
||||
subsection.prereq = expectedRequestData.prereqUsageKey;
|
||||
subsection.prereqMinScore = expectedRequestData.prereqMinScore;
|
||||
subsection.prereqMinCompletion = expectedRequestData.prereqMinCompletion;
|
||||
section.childInfo.children[0] = subsection;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
|
||||
fireEvent.click(subsectionDropdownButton);
|
||||
const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button');
|
||||
fireEvent.click(configureBtn);
|
||||
|
||||
// update fields
|
||||
let configureModal = await findByTestId('configure-modal');
|
||||
let advancedTab = await within(configureModal).findByRole(
|
||||
'tab',
|
||||
{ name: configureModalMessages.advancedTabTitle.defaultMessage },
|
||||
);
|
||||
|
||||
// visibility tab
|
||||
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
|
||||
fireEvent.click(visibilityTab);
|
||||
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(visibilityRadioButtons[2]);
|
||||
|
||||
fireEvent.click(advancedTab);
|
||||
let radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(radioButtons[2]);
|
||||
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||
fireEvent.change(hours, { target: { value: '00:30' } });
|
||||
// select a prerequisite
|
||||
const prereqSelect = await within(configureModal).findByRole('combobox');
|
||||
fireEvent.change(prereqSelect, { target: { value: expectedRequestData.prereqUsageKey } });
|
||||
|
||||
// update minimum score and completion percentage
|
||||
let prereqMinScoreInput = await within(configureModal).findByLabelText(
|
||||
configureModalMessages.minScoreLabel.defaultMessage,
|
||||
);
|
||||
fireEvent.change(prereqMinScoreInput, { target: { value: expectedRequestData.prereqMinScore } });
|
||||
let prereqMinCompletionInput = await within(configureModal).findByLabelText(
|
||||
configureModalMessages.minCompletionLabel.defaultMessage,
|
||||
);
|
||||
fireEvent.change(prereqMinCompletionInput, { target: { value: expectedRequestData.prereqMinCompletion } });
|
||||
|
||||
// enable this subsection to be used as prerequisite by other subsections
|
||||
let prereqCheckbox = await within(configureModal).findByLabelText(
|
||||
configureModalMessages.prereqCheckboxLabel.defaultMessage,
|
||||
);
|
||||
fireEvent.click(prereqCheckbox);
|
||||
|
||||
// fill some rules for proctored exams
|
||||
let examsRulesInput = await within(configureModal).findByLabelText(
|
||||
configureModalMessages.reviewRulesLabel.defaultMessage,
|
||||
);
|
||||
fireEvent.change(examsRulesInput, { target: { value: expectedRequestData.metadata.exam_review_rules } });
|
||||
|
||||
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
|
||||
// verify request
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
|
||||
configureModal = await findByTestId('configure-modal');
|
||||
advancedTab = await within(configureModal).findByRole('tab', {
|
||||
name: configureModalMessages.advancedTabTitle.defaultMessage,
|
||||
});
|
||||
fireEvent.click(advancedTab);
|
||||
radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
expect(radioButtons[0]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[1]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[2]).toHaveProperty('checked', true);
|
||||
hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||
hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||
expect(hours).toHaveValue('00:30');
|
||||
prereqCheckbox = await within(configureModal).findByLabelText(
|
||||
configureModalMessages.prereqCheckboxLabel.defaultMessage,
|
||||
);
|
||||
expect(prereqCheckbox).toBeChecked();
|
||||
const prereqSelectOption = await within(configureModal).findByRole('option', { selected: true });
|
||||
expect(prereqSelectOption).toHaveAttribute('value', expectedRequestData.prereqUsageKey);
|
||||
examsRulesInput = await within(configureModal).findByLabelText(
|
||||
configureModalMessages.reviewRulesLabel.defaultMessage,
|
||||
);
|
||||
expect(examsRulesInput).toHaveTextContent(expectedRequestData.metadata.exam_review_rules);
|
||||
|
||||
prereqMinScoreInput = await within(configureModal).findByLabelText(
|
||||
configureModalMessages.minScoreLabel.defaultMessage,
|
||||
);
|
||||
expect(prereqMinScoreInput).toHaveAttribute('value', `${expectedRequestData.prereqMinScore}`);
|
||||
prereqMinCompletionInput = await within(configureModal).findByLabelText(
|
||||
configureModalMessages.minCompletionLabel.defaultMessage,
|
||||
);
|
||||
expect(prereqMinCompletionInput).toHaveAttribute('value', `${expectedRequestData.prereqMinCompletion}`);
|
||||
});
|
||||
|
||||
it('check practice proctoring settings in configure modal', async () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||
const [subsection] = section.childInfo.children;
|
||||
const expectedRequestData = {
|
||||
publish: 'republish',
|
||||
graderType: 'notgraded',
|
||||
isPrereq: false,
|
||||
prereqMinScore: 100,
|
||||
prereqMinCompletion: 100,
|
||||
metadata: {
|
||||
visible_to_staff_only: null,
|
||||
due: '',
|
||||
hide_after_due: false,
|
||||
show_correctness: 'never',
|
||||
is_practice_exam: true,
|
||||
is_time_limited: true,
|
||||
is_proctored_enabled: true,
|
||||
exam_review_rules: '',
|
||||
default_time_limit_minutes: 30,
|
||||
is_onboarding_exam: false,
|
||||
start: '1970-01-01T05:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(subsection.id), expectedRequestData)
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
const [currentSection] = await findAllByTestId('section-card');
|
||||
const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card');
|
||||
const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button');
|
||||
|
||||
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
|
||||
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
|
||||
subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled;
|
||||
subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam;
|
||||
subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam;
|
||||
subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules;
|
||||
section.childInfo.children[0] = subsection;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
|
||||
fireEvent.click(subsectionDropdownButton);
|
||||
const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button');
|
||||
fireEvent.click(configureBtn);
|
||||
|
||||
// update fields
|
||||
let configureModal = await findByTestId('configure-modal');
|
||||
let advancedTab = await within(configureModal).findByRole(
|
||||
'tab',
|
||||
{ name: configureModalMessages.advancedTabTitle.defaultMessage },
|
||||
);
|
||||
// visibility tab
|
||||
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
|
||||
fireEvent.click(visibilityTab);
|
||||
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(visibilityRadioButtons[4]);
|
||||
|
||||
// advancedTab
|
||||
fireEvent.click(advancedTab);
|
||||
let radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(radioButtons[3]);
|
||||
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||
fireEvent.change(hours, { target: { value: '00:30' } });
|
||||
|
||||
// rules box should not be visible
|
||||
expect(within(configureModal).queryByLabelText(
|
||||
configureModalMessages.reviewRulesLabel.defaultMessage,
|
||||
)).not.toBeInTheDocument();
|
||||
|
||||
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
|
||||
// verify request
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
|
||||
configureModal = await findByTestId('configure-modal');
|
||||
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||
fireEvent.click(advancedTab);
|
||||
radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
expect(radioButtons[0]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[1]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[2]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[3]).toHaveProperty('checked', true);
|
||||
hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||
hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||
expect(hours).toHaveValue('00:30');
|
||||
});
|
||||
|
||||
it('check onboarding proctoring settings in configure modal', async () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||
const [, subsection] = section.childInfo.children;
|
||||
const expectedRequestData = {
|
||||
publish: 'republish',
|
||||
graderType: 'notgraded',
|
||||
isPrereq: true,
|
||||
prereqMinScore: 100,
|
||||
prereqMinCompletion: 100,
|
||||
metadata: {
|
||||
visible_to_staff_only: null,
|
||||
due: '',
|
||||
hide_after_due: false,
|
||||
show_correctness: 'past_due',
|
||||
is_practice_exam: false,
|
||||
is_time_limited: true,
|
||||
is_proctored_enabled: true,
|
||||
exam_review_rules: '',
|
||||
default_time_limit_minutes: 30,
|
||||
is_onboarding_exam: true,
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(subsection.id), expectedRequestData)
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
const [currentSection] = await findAllByTestId('section-card');
|
||||
const [, secondSubsection] = await within(currentSection).findAllByTestId('subsection-card');
|
||||
const subsectionDropdownButton = await within(secondSubsection).findByTestId('subsection-card-header__menu-button');
|
||||
|
||||
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
|
||||
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
|
||||
subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled;
|
||||
subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam;
|
||||
subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam;
|
||||
subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules;
|
||||
section.childInfo.children[1] = subsection;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
|
||||
fireEvent.click(subsectionDropdownButton);
|
||||
const configureBtn = await within(secondSubsection).findByTestId('subsection-card-header__menu-configure-button');
|
||||
fireEvent.click(configureBtn);
|
||||
|
||||
// update fields
|
||||
let configureModal = await findByTestId('configure-modal');
|
||||
// visibility tab
|
||||
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
|
||||
fireEvent.click(visibilityTab);
|
||||
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(visibilityRadioButtons[5]);
|
||||
|
||||
// advancedTab
|
||||
let advancedTab = await within(configureModal).findByRole(
|
||||
'tab',
|
||||
{ name: configureModalMessages.advancedTabTitle.defaultMessage },
|
||||
);
|
||||
fireEvent.click(advancedTab);
|
||||
let radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(radioButtons[3]);
|
||||
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||
fireEvent.change(hours, { target: { value: '00:30' } });
|
||||
|
||||
// rules box should not be visible
|
||||
expect(within(configureModal).queryByLabelText(
|
||||
configureModalMessages.reviewRulesLabel.defaultMessage,
|
||||
)).not.toBeInTheDocument();
|
||||
|
||||
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
|
||||
// verify request
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
|
||||
configureModal = await findByTestId('configure-modal');
|
||||
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||
fireEvent.click(advancedTab);
|
||||
radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
expect(radioButtons[0]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[1]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[2]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[3]).toHaveProperty('checked', true);
|
||||
hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||
hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||
expect(hours).toHaveValue('00:30');
|
||||
});
|
||||
|
||||
it('check no special exam setting in configure modal', async () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[1]);
|
||||
const [subsection] = section.childInfo.children;
|
||||
const expectedRequestData = {
|
||||
publish: 'republish',
|
||||
graderType: 'notgraded',
|
||||
prereqMinScore: 100,
|
||||
prereqMinCompletion: 100,
|
||||
metadata: {
|
||||
visible_to_staff_only: null,
|
||||
due: '',
|
||||
hide_after_due: false,
|
||||
show_correctness: 'always',
|
||||
is_practice_exam: false,
|
||||
is_time_limited: false,
|
||||
is_proctored_enabled: false,
|
||||
exam_review_rules: '',
|
||||
default_time_limit_minutes: 0,
|
||||
is_onboarding_exam: false,
|
||||
start: '1970-01-01T05:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(subsection.id), expectedRequestData)
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
const [, currentSection] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(currentSection).findAllByTestId('subsection-card');
|
||||
const subsectionDropdownButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-button');
|
||||
|
||||
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
|
||||
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
|
||||
subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled;
|
||||
subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam;
|
||||
subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam;
|
||||
subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules;
|
||||
section.childInfo.children[0] = subsection;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
|
||||
fireEvent.click(subsectionDropdownButton);
|
||||
const configureBtn = await within(subsectionElement).findByTestId('subsection-card-header__menu-configure-button');
|
||||
fireEvent.click(configureBtn);
|
||||
|
||||
// update fields
|
||||
let configureModal = await findByTestId('configure-modal');
|
||||
|
||||
// advancedTab
|
||||
let advancedTab = await within(configureModal).findByRole(
|
||||
'tab',
|
||||
{ name: configureModalMessages.advancedTabTitle.defaultMessage },
|
||||
);
|
||||
fireEvent.click(advancedTab);
|
||||
let radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(radioButtons[0]);
|
||||
|
||||
// time box should not be visible
|
||||
expect(within(configureModal).queryByLabelText(
|
||||
configureModalMessages.timeAllotted.defaultMessage,
|
||||
)).not.toBeInTheDocument();
|
||||
|
||||
// rules box should not be visible
|
||||
expect(within(configureModal).queryByLabelText(
|
||||
configureModalMessages.reviewRulesLabel.defaultMessage,
|
||||
)).not.toBeInTheDocument();
|
||||
|
||||
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
|
||||
// verify request
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
|
||||
configureModal = await findByTestId('configure-modal');
|
||||
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||
fireEvent.click(advancedTab);
|
||||
radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
expect(radioButtons[0]).toHaveProperty('checked', true);
|
||||
expect(radioButtons[1]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[2]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[3]).toHaveProperty('checked', false);
|
||||
});
|
||||
|
||||
it('check configure modal for unit', async () => {
|
||||
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [unit] = subsection.childInfo.children;
|
||||
// Enrollment Track Groups : Audit
|
||||
const newGroupAccess = { 50: [1] };
|
||||
const isVisibleToStaffOnly = true;
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(unit.id), {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
visible_to_staff_only: isVisibleToStaffOnly,
|
||||
group_access: newGroupAccess,
|
||||
},
|
||||
})
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
|
||||
const [firstSection] = await findAllByTestId('section-card');
|
||||
const [firstSubsection] = await within(firstSection).findAllByTestId('subsection-card');
|
||||
const subsectionExpandButton = await within(firstSubsection).getByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(subsectionExpandButton);
|
||||
const [firstUnit] = await within(firstSubsection).findAllByTestId('unit-card');
|
||||
const unitDropdownButton = await within(firstUnit).findByTestId('unit-card-header__menu-button');
|
||||
|
||||
// after configuraiton response
|
||||
unit.visibilityState = 'staff_only';
|
||||
unit.userPartitionInfo = {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: true,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: 0,
|
||||
selectedGroupsLabel: '',
|
||||
};
|
||||
subsection.childInfo.children[0] = unit;
|
||||
section.childInfo.children[0] = subsection;
|
||||
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
|
||||
fireEvent.click(unitDropdownButton);
|
||||
const configureBtn = await within(firstUnit).getByTestId('unit-card-header__menu-configure-button');
|
||||
fireEvent.click(configureBtn);
|
||||
|
||||
let configureModal = await findByTestId('configure-modal');
|
||||
expect(await within(configureModal).findByText(
|
||||
configureModalMessages.unitVisibility.defaultMessage,
|
||||
)).toBeInTheDocument();
|
||||
let visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
|
||||
await act(async () => fireEvent.click(visibilityCheckbox));
|
||||
|
||||
let groupeType = await within(configureModal).findByTestId('group-type-select');
|
||||
fireEvent.change(groupeType, { target: { value: '0' } });
|
||||
|
||||
let checkboxes = await within(await within(configureModal).findByTestId('group-checkboxes')).findAllByRole('checkbox');
|
||||
fireEvent.click(checkboxes[1]);
|
||||
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(unitDropdownButton));
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
|
||||
configureModal = await findByTestId('configure-modal');
|
||||
visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
|
||||
expect(visibilityCheckbox).toBeChecked();
|
||||
|
||||
groupeType = await within(configureModal).findByTestId('group-type-select');
|
||||
expect(groupeType).toHaveValue('0');
|
||||
|
||||
checkboxes = await within(await within(configureModal).findByTestId('group-checkboxes')).findAllByRole('checkbox');
|
||||
|
||||
expect(checkboxes[0]).not.toBeChecked();
|
||||
expect(checkboxes[1]).toBeChecked();
|
||||
});
|
||||
|
||||
it('check update highlights when update highlights query is successfully', async () => {
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const highlights = [
|
||||
'New Highlight 1',
|
||||
'New Highlight 2',
|
||||
'New Highlight 3',
|
||||
'New Highlight 4',
|
||||
'New Highlight 5',
|
||||
];
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(section.id), {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
highlights,
|
||||
},
|
||||
})
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, {
|
||||
...section,
|
||||
highlights,
|
||||
});
|
||||
|
||||
await executeThunk(updateCourseSectionHighlightsQuery(section.id, highlights), store.dispatch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByRole('button', { name: '5 Section highlights' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('check whether section move up and down options work correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// get second section element
|
||||
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||
const [, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
|
||||
// mock api call
|
||||
axiosMock
|
||||
.onPut(getCourseBlockApiUrl(courseBlockId))
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
// find menu button and click on it to open menu
|
||||
const menu = await within(sectionElement).findByTestId('section-card-header__menu-button');
|
||||
fireEvent.click(menu);
|
||||
|
||||
// move second section to first position to test move up option
|
||||
const moveUpButton = await within(sectionElement).findByTestId('section-card-header__menu-move-up-button');
|
||||
await act(async () => fireEvent.click(moveUpButton));
|
||||
const firstSectionId = store.getState().courseOutline.sectionsList[0].id;
|
||||
expect(secondSection.id).toBe(firstSectionId);
|
||||
|
||||
// move first section back to second position to test move down option
|
||||
const moveDownButton = await within(sectionElement).findByTestId('section-card-header__menu-move-down-button');
|
||||
await act(async () => fireEvent.click(moveDownButton));
|
||||
const newSecondSectionId = store.getState().courseOutline.sectionsList[1].id;
|
||||
expect(secondSection.id).toBe(newSecondSectionId);
|
||||
});
|
||||
|
||||
it('check whether section move up & down option is rendered correctly based on index', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// get first, second and last section element
|
||||
const {
|
||||
0: firstSection, 1: secondSection, length, [length - 1]: lastSection,
|
||||
} = await findAllByTestId('section-card');
|
||||
|
||||
// find menu button and click on it to open menu in first section
|
||||
const firstMenu = await within(firstSection).findByTestId('section-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(firstMenu));
|
||||
// move down option should be enabled in first element
|
||||
expect(
|
||||
await within(firstSection).findByTestId('section-card-header__menu-move-down-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
// move up option should not be enabled in first element
|
||||
expect(
|
||||
await within(firstSection).findByTestId('section-card-header__menu-move-up-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
// find menu button and click on it to open menu in second section
|
||||
const secondMenu = await within(secondSection).findByTestId('section-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(secondMenu));
|
||||
// both move down & up option should be enabled in second element
|
||||
expect(
|
||||
await within(secondSection).findByTestId('section-card-header__menu-move-down-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
expect(
|
||||
await within(secondSection).findByTestId('section-card-header__menu-move-up-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
|
||||
// find menu button and click on it to open menu in last section
|
||||
const lastMenu = await within(lastSection).findByTestId('section-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(lastMenu));
|
||||
// move down option should not be enabled in last element
|
||||
expect(
|
||||
await within(lastSection).findByTestId('section-card-header__menu-move-down-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
// move up option should be enabled in last element
|
||||
expect(
|
||||
await within(lastSection).findByTestId('section-card-header__menu-move-up-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
});
|
||||
|
||||
it('check whether subsection move up and down options work correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// get second section element
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [, secondSubsection] = section.childInfo.children;
|
||||
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
|
||||
// mock api call
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[0].id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
// find menu button and click on it to open menu
|
||||
const menu = await within(subsectionElement).findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menu));
|
||||
|
||||
// move second subsection to first position to test move up option
|
||||
const moveUpButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-move-up-button');
|
||||
await act(async () => fireEvent.click(moveUpButton));
|
||||
const firstSubsectionId = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id;
|
||||
expect(secondSubsection.id).toBe(firstSubsectionId);
|
||||
|
||||
// move first section back to second position to test move down option
|
||||
const moveDownButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-move-down-button');
|
||||
await act(async () => fireEvent.click(moveDownButton));
|
||||
const secondSubsectionId = store.getState().courseOutline.sectionsList[0].childInfo.children[1].id;
|
||||
expect(secondSubsection.id).toBe(secondSubsectionId);
|
||||
});
|
||||
|
||||
it('check whether subsection move up & down option is rendered correctly based on index', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// using second section as second section in mock has 3 subsections
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
// get first, second and last subsection element
|
||||
const {
|
||||
0: firstSubsection,
|
||||
1: secondSubsection,
|
||||
length,
|
||||
[length - 1]: lastSubsection,
|
||||
} = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
|
||||
// find menu button and click on it to open menu in first section
|
||||
const firstMenu = await within(firstSubsection).findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(firstMenu));
|
||||
// move down option should be enabled in first element
|
||||
expect(
|
||||
await within(firstSubsection).findByTestId('subsection-card-header__menu-move-down-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
// move up option should not be enabled in first element
|
||||
expect(
|
||||
await within(firstSubsection).findByTestId('subsection-card-header__menu-move-up-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
// find menu button and click on it to open menu in second section
|
||||
const secondMenu = await within(secondSubsection).findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(secondMenu));
|
||||
// both move down & up option should be enabled in second element
|
||||
expect(
|
||||
await within(secondSubsection).findByTestId('subsection-card-header__menu-move-down-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
expect(
|
||||
await within(secondSubsection).findByTestId('subsection-card-header__menu-move-up-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
|
||||
// find menu button and click on it to open menu in last section
|
||||
const lastMenu = await within(lastSubsection).findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(lastMenu));
|
||||
// move down option should not be enabled in last element
|
||||
expect(
|
||||
await within(lastSubsection).findByTestId('subsection-card-header__menu-move-down-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
// move up option should be enabled in last element
|
||||
expect(
|
||||
await within(lastSubsection).findByTestId('subsection-card-header__menu-move-up-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
});
|
||||
|
||||
it('check whether unit move up and down options work correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// get second section -> second subsection -> second unit element
|
||||
const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
const [, subsection] = section.childInfo.children;
|
||||
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
await act(async () => fireEvent.click(expandBtn));
|
||||
const [, secondUnit] = subsection.childInfo.children;
|
||||
const [, unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
// mock api call
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[1].childInfo.children[1].id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
// find menu button and click on it to open menu
|
||||
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menu));
|
||||
|
||||
// move second unit to first position to test move up option
|
||||
const moveUpButton = await within(unitElement).findByTestId('unit-card-header__menu-move-up-button');
|
||||
await act(async () => fireEvent.click(moveUpButton));
|
||||
const firstUnitId = store.getState().courseOutline.sectionsList[1].childInfo.children[1].childInfo.children[0].id;
|
||||
expect(secondUnit.id).toBe(firstUnitId);
|
||||
|
||||
// move first unit back to second position to test move down option
|
||||
const moveDownButton = await within(subsectionElement).findByTestId('unit-card-header__menu-move-down-button');
|
||||
await act(async () => fireEvent.click(moveDownButton));
|
||||
const secondUnitId = store.getState().courseOutline.sectionsList[1].childInfo.children[1].childInfo.children[1].id;
|
||||
expect(secondUnit.id).toBe(secondUnitId);
|
||||
});
|
||||
|
||||
it('check whether unit move up & down option is rendered correctly based on index', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// using second section -> second subsection as it has 5 units in mock.
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
await act(async () => fireEvent.click(expandBtn));
|
||||
// get first, second and last unit element
|
||||
const {
|
||||
0: firstUnit,
|
||||
1: secondUnit,
|
||||
length,
|
||||
[length - 1]: lastUnit,
|
||||
} = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
// find menu button and click on it to open menu in first section
|
||||
const firstMenu = await within(firstUnit).findByTestId('unit-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(firstMenu));
|
||||
// move down option should be enabled in first element
|
||||
expect(
|
||||
await within(firstUnit).findByTestId('unit-card-header__menu-move-down-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
// move up option should not be enabled in first element
|
||||
expect(
|
||||
await within(firstUnit).findByTestId('unit-card-header__menu-move-up-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
// find menu button and click on it to open menu in second section
|
||||
const secondMenu = await within(secondUnit).findByTestId('unit-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(secondMenu));
|
||||
// both move down & up option should be enabled in second element
|
||||
expect(
|
||||
await within(secondUnit).findByTestId('unit-card-header__menu-move-down-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
expect(
|
||||
await within(secondUnit).findByTestId('unit-card-header__menu-move-up-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
|
||||
// find menu button and click on it to open menu in last section
|
||||
const lastMenu = await within(lastUnit).findByTestId('unit-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(lastMenu));
|
||||
// move down option should not be enabled in last element
|
||||
expect(
|
||||
await within(lastUnit).findByTestId('unit-card-header__menu-move-down-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
// move up option should be enabled in last element
|
||||
expect(
|
||||
await within(lastUnit).findByTestId('unit-card-header__menu-move-up-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
});
|
||||
|
||||
it('check that new section list is saved when dragged', async () => {
|
||||
const { findAllByRole } = render(<RootWrapper />);
|
||||
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||
const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = sectionsDraggers[7];
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseBlockApiUrl(courseBlockId))
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
const section1 = store.getState().courseOutline.sectionsList[0].id;
|
||||
|
||||
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||
await waitFor(async () => {
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
|
||||
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
|
||||
const section2 = store.getState().courseOutline.sectionsList[1].id;
|
||||
expect(section1).toBe(section2);
|
||||
});
|
||||
|
||||
it('check section list is restored to original order when API call fails', async () => {
|
||||
const { findAllByRole } = render(<RootWrapper />);
|
||||
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||
const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = sectionsDraggers[6];
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseBlockApiUrl(courseBlockId))
|
||||
.reply(500);
|
||||
|
||||
const section1 = store.getState().courseOutline.sectionsList[0].id;
|
||||
|
||||
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||
await waitFor(async () => {
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
|
||||
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.FAILED);
|
||||
});
|
||||
|
||||
const section1New = store.getState().courseOutline.sectionsList[0].id;
|
||||
expect(section1).toBe(section1New);
|
||||
});
|
||||
|
||||
it('check that new subsection list is saved when dragged', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [section] = store.getState().courseOutline.sectionsList;
|
||||
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = subsectionsDraggers[1];
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(section.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
const subsection1 = section.childInfo.children[0].id;
|
||||
|
||||
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||
await waitFor(async () => {
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
|
||||
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
|
||||
const subsection2 = store.getState().courseOutline.sectionsList[0].childInfo.children[1].id;
|
||||
expect(subsection1).toBe(subsection2);
|
||||
});
|
||||
|
||||
it('check that new subsection list is restored to original order when API call fails', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [section] = store.getState().courseOutline.sectionsList;
|
||||
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = subsectionsDraggers[1];
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(section.id))
|
||||
.reply(500);
|
||||
|
||||
const subsection1 = section.childInfo.children[0].id;
|
||||
|
||||
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||
await waitFor(async () => {
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
|
||||
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.FAILED);
|
||||
});
|
||||
|
||||
const subsection1New = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id;
|
||||
expect(subsection1).toBe(subsection1New);
|
||||
});
|
||||
|
||||
it('check that new unit list is saved when dragged', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const subsectionElement = (await findAllByTestId('subsection-card'))[3];
|
||||
const [subsection] = store.getState().courseOutline.sectionsList[1].childInfo.children;
|
||||
const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandBtn);
|
||||
const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = unitDraggers[1];
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(subsection.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
const unit1 = subsection.childInfo.children[0].id;
|
||||
|
||||
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||
await waitFor(async () => {
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
|
||||
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
|
||||
const unit2 = store.getState().courseOutline.sectionsList[1].childInfo.children[0].childInfo.children[1].id;
|
||||
expect(unit1).toBe(unit2);
|
||||
});
|
||||
|
||||
it('check that new unit list is restored to original order when API call fails', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const subsectionElement = (await findAllByTestId('subsection-card'))[3];
|
||||
const [subsection] = store.getState().courseOutline.sectionsList[1].childInfo.children;
|
||||
const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandBtn);
|
||||
const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = unitDraggers[1];
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(subsection.id))
|
||||
.reply(500);
|
||||
|
||||
const unit1 = subsection.childInfo.children[0].id;
|
||||
|
||||
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||
await waitFor(async () => {
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
|
||||
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.FAILED);
|
||||
});
|
||||
|
||||
const unit1New = store.getState().courseOutline.sectionsList[1].childInfo.children[0].childInfo.children[0].id;
|
||||
expect(unit1).toBe(unit1New);
|
||||
});
|
||||
|
||||
it('check that drag handle is not visible for non-draggable sections', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...courseOutlineIndexMock,
|
||||
courseStructure: {
|
||||
...courseOutlineIndexMock.courseStructure,
|
||||
childInfo: {
|
||||
...courseOutlineIndexMock.courseStructure.childInfo,
|
||||
children: [
|
||||
{
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children[0],
|
||||
actions: {
|
||||
draggable: false,
|
||||
childAddable: true,
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
},
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children.slice(1),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const [sectionElement] = await findAllByTestId('conditional-sortable-element--no-drag-handle');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(sectionElement).queryByText(section.displayName)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('check whether unit copy & paste option works correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// get first section -> first subsection -> first unit element
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsection] = section.childInfo.children;
|
||||
let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
await act(async () => fireEvent.click(expandBtn));
|
||||
const [unit] = subsection.childInfo.children;
|
||||
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
const expectedClipboardContent = {
|
||||
content: {
|
||||
blockType: 'vertical',
|
||||
blockTypeDisplay: 'Unit',
|
||||
created: '2024-01-29T07:58:36.844249Z',
|
||||
displayName: unit.displayName,
|
||||
id: 15,
|
||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/15/olx',
|
||||
purpose: 'clipboard',
|
||||
status: 'ready',
|
||||
userId: 3,
|
||||
},
|
||||
sourceUsageKey: unit.id,
|
||||
sourceContexttitle: courseOutlineIndexMock.courseStructure.displayName,
|
||||
sourceEditUrl: unit.studioUrl,
|
||||
};
|
||||
// mock api call
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl(), {
|
||||
usage_key: unit.id,
|
||||
}).reply(200, expectedClipboardContent);
|
||||
// check that initialUserClipboard state is empty
|
||||
const { initialUserClipboard } = store.getState().courseOutline;
|
||||
expect(initialUserClipboard).toBeUndefined();
|
||||
|
||||
// find menu button and click on it to open menu
|
||||
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menu));
|
||||
|
||||
// move first unit back to second position to test move down option
|
||||
const copyButton = await within(unitElement).findByText(cardHeaderMessages.menuCopy.defaultMessage);
|
||||
await act(async () => fireEvent.click(copyButton));
|
||||
|
||||
// check that initialUserClipboard state is updated
|
||||
expect(store.getState().courseOutline.initialUserClipboard).toEqual(expectedClipboardContent);
|
||||
|
||||
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
// find clipboard content label
|
||||
const clipboardLabel = await within(subsectionElement).findByText(
|
||||
pasteButtonMessages.clipboardContentLabel.defaultMessage,
|
||||
);
|
||||
await act(async () => fireEvent.mouseOver(clipboardLabel));
|
||||
|
||||
// find clipboard content popup link
|
||||
expect(
|
||||
subsectionElement.querySelector('#vertical-paste-button-overlay'),
|
||||
).toHaveAttribute('href', unit.studioUrl);
|
||||
|
||||
// check paste button functionality
|
||||
// mock api call
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl(), {
|
||||
parent_locator: subsection.id,
|
||||
staged_content: 'clipboard',
|
||||
}).reply(200, { dummy: 'value' });
|
||||
const pasteBtn = await within(subsectionElement).findByText(subsectionMessages.pasteButton.defaultMessage);
|
||||
await act(async () => fireEvent.click(pasteBtn));
|
||||
|
||||
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const lastUnitElement = (await within(subsectionElement).findAllByTestId('unit-card')).slice(-1)[0];
|
||||
expect(lastUnitElement).toHaveTextContent(unit.displayName);
|
||||
});
|
||||
});
|
||||
43
src/course-outline/__mocks__/courseBestPractices.js
Normal file
@@ -0,0 +1,43 @@
|
||||
module.exports = {
|
||||
isSelfPaced: false,
|
||||
sections: {
|
||||
totalNumber: 6,
|
||||
totalVisible: 4,
|
||||
numberWithHighlights: 2,
|
||||
highlightsActiveForCourse: true,
|
||||
highlightsEnabled: true,
|
||||
},
|
||||
subsections: {
|
||||
totalVisible: 5,
|
||||
numWithOneBlockType: 2,
|
||||
numBlockTypes: {
|
||||
min: 0,
|
||||
max: 3,
|
||||
mean: 1,
|
||||
median: 1,
|
||||
mode: 1,
|
||||
},
|
||||
},
|
||||
units: {
|
||||
totalVisible: 9,
|
||||
numBlocks: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
mean: 2,
|
||||
median: 2,
|
||||
mode: 2,
|
||||
},
|
||||
},
|
||||
videos: {
|
||||
totalNumber: 7,
|
||||
numMobileEncoded: 0,
|
||||
numWithValId: 3,
|
||||
durations: {
|
||||
min: null,
|
||||
max: null,
|
||||
mean: null,
|
||||
median: null,
|
||||
mode: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
31
src/course-outline/__mocks__/courseLaunch.js
Normal file
@@ -0,0 +1,31 @@
|
||||
module.exports = {
|
||||
isSelfPaced: false,
|
||||
dates: {
|
||||
hasStartDate: true,
|
||||
hasEndDate: false,
|
||||
},
|
||||
assignments: {
|
||||
totalNumber: 11,
|
||||
totalVisible: 7,
|
||||
assignmentsWithDatesBeforeStart: [],
|
||||
assignmentsWithDatesAfterEnd: [],
|
||||
assignmentsWithOraDatesBeforeStart: [],
|
||||
assignmentsWithOraDatesAfterEnd: [],
|
||||
},
|
||||
grades: {
|
||||
hasGradingPolicy: true,
|
||||
sumOfWeights: 1,
|
||||
},
|
||||
certificates: {
|
||||
isActivated: false,
|
||||
hasCertificate: false,
|
||||
isEnabled: true,
|
||||
},
|
||||
updates: {
|
||||
hasUpdate: true,
|
||||
},
|
||||
proctoring: {
|
||||
needsProctoringEscalationEmail: false,
|
||||
hasProctoringEscalationEmail: false,
|
||||
},
|
||||
};
|
||||
3063
src/course-outline/__mocks__/courseOutlineIndex.js
Normal file
@@ -0,0 +1,3063 @@
|
||||
module.exports = {
|
||||
courseReleaseDate: 'Set Date',
|
||||
courseStructure: {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@course+block@course',
|
||||
displayName: 'Demonstration Course',
|
||||
category: 'course',
|
||||
hasChildren: true,
|
||||
unitLevelDiscussions: false,
|
||||
editedOn: 'Aug 23, 2023 at 12:35 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Aug 23, 2023 at 11:32 UTC',
|
||||
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course',
|
||||
releasedToStudents: false,
|
||||
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
|
||||
visibilityState: null,
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2023-11-09T22:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
videoSharingEnabled: true,
|
||||
videoSharingOptions: 'per-video',
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
highlightsEnabledForMessaging: false,
|
||||
highlightsEnabled: true,
|
||||
highlightsPreviewOnly: false,
|
||||
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||
enableProctoredExams: true,
|
||||
createZendeskTickets: true,
|
||||
enableTimedExams: true,
|
||||
childInfo: {
|
||||
category: 'chapter',
|
||||
displayName: 'Section',
|
||||
children: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b',
|
||||
displayName: 'Introduction 12',
|
||||
category: 'chapter',
|
||||
hasChildren: true,
|
||||
editedOn: 'Aug 23, 2023 at 12:35 UTC',
|
||||
published: false,
|
||||
publishedOn: 'Aug 23, 2023 at 12:35 UTC',
|
||||
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d8a6192ade314473a78242dfeedfbf5b',
|
||||
releasedToStudents: false,
|
||||
releaseDate: 'Aug 10, 2023 at 22:00 UTC',
|
||||
visibilityState: 'staff_only',
|
||||
hasExplicitStaffLock: true,
|
||||
start: '2023-08-10T22:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
highlights: [
|
||||
'New Highlight 1',
|
||||
'New Highlight 4',
|
||||
],
|
||||
highlightsEnabled: true,
|
||||
highlightsPreviewOnly: false,
|
||||
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||
childInfo: {
|
||||
category: 'sequential',
|
||||
displayName: 'Subsection',
|
||||
children: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction',
|
||||
displayName: 'Demo Course Overview',
|
||||
category: 'sequential',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: false,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40edx_introduction',
|
||||
releasedToStudents: false,
|
||||
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||
visibilityState: 'needs_attention',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '1970-01-01T05:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
isPrereq: false,
|
||||
prereqs: [{
|
||||
blockDisplayName: 'Sample Subsection',
|
||||
blockUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@7f75de8dcc261249250b71925f49810f',
|
||||
}],
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
hideAfterDue: false,
|
||||
isProctoredExam: false,
|
||||
wasExamEverLinkedWithExternal: false,
|
||||
onlineProctoringRules: '',
|
||||
isPracticeExam: false,
|
||||
isOnboardingExam: false,
|
||||
isTimeLimited: false,
|
||||
examReviewRules: '',
|
||||
defaultTimeLimitMinutes: null,
|
||||
proctoringExamConfigurationLink: null,
|
||||
supportsOnboarding: false,
|
||||
showReviewRules: true,
|
||||
childInfo: {
|
||||
category: 'vertical',
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
||||
displayName: 'Introduction: Video and Sequences',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: false,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
||||
releasedToStudents: false,
|
||||
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||
visibilityState: 'needs_attention',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '1970-01-01T05:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: true,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
enableCopyPasteUnits: true,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ancestorHasStaffLock: true,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
enableCopyPasteUnits: true,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@7f75de8dcc261249250b71925f49810f',
|
||||
display_name: 'Sample Subsection',
|
||||
category: 'sequential',
|
||||
has_children: true,
|
||||
edited_on: 'Dec 05, 2023 at 10:35 UTC',
|
||||
published: true,
|
||||
published_on: 'Dec 05, 2023 at 10:35 UTC',
|
||||
studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%407f75de8dcc261249250b71925f49810f',
|
||||
released_to_students: true,
|
||||
release_date: 'Feb 05, 2013 at 05:00 UTC',
|
||||
visibility_state: 'live',
|
||||
has_explicit_staff_lock: false,
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
graded: false,
|
||||
due_date: '',
|
||||
due: null,
|
||||
relative_weeks_due: null,
|
||||
format: null,
|
||||
course_graders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
has_changes: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatory_message: null,
|
||||
group_access: {},
|
||||
user_partitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
show_correctness: 'always',
|
||||
hide_after_due: false,
|
||||
is_proctored_exam: false,
|
||||
was_exam_ever_linked_with_external: false,
|
||||
online_proctoring_rules: '',
|
||||
is_practice_exam: false,
|
||||
is_onboarding_exam: false,
|
||||
is_time_limited: false,
|
||||
isPrereq: true,
|
||||
exam_review_rules: '',
|
||||
default_time_limit_minutes: null,
|
||||
proctoring_exam_configuration_link: null,
|
||||
supports_onboarding: true,
|
||||
show_review_rules: true,
|
||||
child_info: {
|
||||
category: 'vertical',
|
||||
display_name: 'Unit',
|
||||
children: [],
|
||||
},
|
||||
ancestor_has_staff_lock: false,
|
||||
staff_only_message: false,
|
||||
enable_copy_paste_units: true,
|
||||
has_partition_group_components: false,
|
||||
user_partition_info: {
|
||||
selectable_partitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selected_partition_index: -1,
|
||||
selected_groups_label: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: true,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions',
|
||||
displayName: 'Example Week 2: Get Interactive',
|
||||
category: 'chapter',
|
||||
hasChildren: true,
|
||||
editedOn: 'Aug 16, 2023 at 11:52 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Aug 16, 2023 at 11:52 UTC',
|
||||
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40graded_interactions',
|
||||
releasedToStudents: false,
|
||||
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
|
||||
visibilityState: 'ready',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2023-11-09T22:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
highlights: [
|
||||
'New',
|
||||
],
|
||||
highlightsEnabled: true,
|
||||
highlightsPreviewOnly: false,
|
||||
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||
childInfo: {
|
||||
category: 'sequential',
|
||||
displayName: 'Subsection',
|
||||
children: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations',
|
||||
displayName: "Lesson 2 - Let's Get Interactive!",
|
||||
category: 'sequential',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40simulations',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '1970-01-01T05:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
hideAfterDue: false,
|
||||
isProctoredExam: true,
|
||||
wasExamEverLinkedWithExternal: false,
|
||||
onlineProctoringRules: '',
|
||||
isPracticeExam: false,
|
||||
isOnboardingExam: false,
|
||||
isTimeLimited: true,
|
||||
examReviewRules: '',
|
||||
defaultTimeLimitMinutes: null,
|
||||
proctoringExamConfigurationLink: null,
|
||||
supportsOnboarding: false,
|
||||
showReviewRules: true,
|
||||
childInfo: {
|
||||
category: 'vertical',
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d0d804e8863c4a95a659c04d8a2b2bc0',
|
||||
displayName: "Lesson 2 - Let's Get Interactive! ",
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d0d804e8863c4a95a659c04d8a2b2bc0',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '1970-01-01T05:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_2dbb0072785e',
|
||||
displayName: 'An Interactive Reference Table',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_2dbb0072785e',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '1970-01-01T05:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_98cf62510471',
|
||||
displayName: 'Zooming Diagrams',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_98cf62510471',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '1970-01-01T05:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_d32bf9b2242c',
|
||||
displayName: 'Electronic Sound Experiment',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_d32bf9b2242c',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '1970-01-01T05:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4e592689563243c484af947465eaef0d',
|
||||
displayName: 'New Unit',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@4e592689563243c484af947465eaef0d',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '1970-01-01T05:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations',
|
||||
displayName: 'Homework - Labs and Demos',
|
||||
category: 'sequential',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40graded_simulations',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: 'Homework',
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
hideAfterDue: false,
|
||||
isProctoredExam: false,
|
||||
wasExamEverLinkedWithExternal: false,
|
||||
onlineProctoringRules: '',
|
||||
isPracticeExam: false,
|
||||
isOnboardingExam: false,
|
||||
isTimeLimited: false,
|
||||
examReviewRules: '',
|
||||
defaultTimeLimitMinutes: null,
|
||||
proctoringExamConfigurationLink: null,
|
||||
supportsOnboarding: false,
|
||||
showReviewRules: true,
|
||||
childInfo: {
|
||||
category: 'vertical',
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6cee45205a449369d7ef8f159b22bdf',
|
||||
displayName: 'Labs and Demos',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6cee45205a449369d7ef8f159b22bdf',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_aae927868e55',
|
||||
displayName: 'Code Grader',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_aae927868e55',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_c037f3757df1',
|
||||
displayName: 'Electric Circuit Simulator',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_c037f3757df1',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_bc69a47c6fae',
|
||||
displayName: 'Protein Creator',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_bc69a47c6fae',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@8f89194410954e768bde1764985454a7',
|
||||
displayName: 'Molecule Structures',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@8f89194410954e768bde1764985454a7',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e',
|
||||
displayName: 'Homework - Essays',
|
||||
category: 'sequential',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40175e76c4951144a29d46211361266e0e',
|
||||
releasedToStudents: false,
|
||||
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
|
||||
visibilityState: 'ready',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2023-11-09T22:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
hideAfterDue: false,
|
||||
isProctoredExam: false,
|
||||
wasExamEverLinkedWithExternal: false,
|
||||
onlineProctoringRules: '',
|
||||
isPracticeExam: false,
|
||||
isOnboardingExam: false,
|
||||
isTimeLimited: false,
|
||||
examReviewRules: '',
|
||||
defaultTimeLimitMinutes: null,
|
||||
proctoringExamConfigurationLink: null,
|
||||
supportsOnboarding: false,
|
||||
showReviewRules: true,
|
||||
childInfo: {
|
||||
category: 'vertical',
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@fb79dcbad35b466a8c6364f8ffee9050',
|
||||
displayName: 'Peer Assessed Essays',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@fb79dcbad35b466a8c6364f8ffee9050',
|
||||
releasedToStudents: false,
|
||||
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
|
||||
visibilityState: 'ready',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2023-11-09T22:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@1414ffd5143b4b508f739b563ab468b7',
|
||||
displayName: 'About Exams and Certificates',
|
||||
category: 'chapter',
|
||||
hasChildren: true,
|
||||
editedOn: 'Aug 10, 2023 at 10:40 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Aug 10, 2023 at 10:40 UTC',
|
||||
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%401414ffd5143b4b508f739b563ab468b7',
|
||||
releasedToStudents: false,
|
||||
releaseDate: 'Jan 01, 2030 at 05:00 UTC',
|
||||
visibilityState: 'needs_attention',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2030-01-01T05:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
highlights: [],
|
||||
highlightsEnabled: true,
|
||||
highlightsPreviewOnly: false,
|
||||
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||
childInfo: {
|
||||
category: 'sequential',
|
||||
displayName: 'Subsection',
|
||||
children: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow',
|
||||
displayName: 'edX Exams',
|
||||
category: 'sequential',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40workflow',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: 'Exam',
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
hideAfterDue: false,
|
||||
isProctoredExam: false,
|
||||
wasExamEverLinkedWithExternal: false,
|
||||
onlineProctoringRules: '',
|
||||
isPracticeExam: false,
|
||||
isOnboardingExam: false,
|
||||
isTimeLimited: false,
|
||||
examReviewRules: '',
|
||||
defaultTimeLimitMinutes: null,
|
||||
proctoringExamConfigurationLink: null,
|
||||
supportsOnboarding: false,
|
||||
showReviewRules: true,
|
||||
childInfo: {
|
||||
category: 'vertical',
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@934cc32c177d41b580c8413e561346b3',
|
||||
displayName: 'EdX Exams',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@934cc32c177d41b580c8413e561346b3',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_f04afeac0131',
|
||||
displayName: 'Immediate Feedback',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_f04afeac0131',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@b6662b497c094bcc9b870d8270c90c93',
|
||||
displayName: 'Getting Answers',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@b6662b497c094bcc9b870d8270c90c93',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@f91d8d31f7cf48ce990f8d8745ae4cfa',
|
||||
displayName: 'Answering More Than Once',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@f91d8d31f7cf48ce990f8d8745ae4cfa',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_ac391cde8a91',
|
||||
displayName: 'Limited Checks',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_ac391cde8a91',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_36e0beb03f0a',
|
||||
displayName: 'Randomized Questions',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_36e0beb03f0a',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@1b0e2c2c84884b95b1c99fb678cc964c',
|
||||
displayName: 'Overall Grade Performance',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@1b0e2c2c84884b95b1c99fb678cc964c',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff',
|
||||
displayName: 'Passing a Course',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6eaa391d2be41dea20b8b1bfbcb1c45',
|
||||
displayName: 'Getting Your edX Certificate',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6eaa391d2be41dea20b8b1bfbcb1c45',
|
||||
releasedToStudents: true,
|
||||
releaseDate: 'Feb 05, 2013 at 00:00 UTC',
|
||||
visibilityState: 'live',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2013-02-05T00:00:00Z',
|
||||
graded: true,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@46e11a7b395f45b9837df6c6ac609004',
|
||||
displayName: 'Publish section',
|
||||
category: 'chapter',
|
||||
hasChildren: true,
|
||||
editedOn: 'Aug 23, 2023 at 12:22 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Aug 23, 2023 at 12:22 UTC',
|
||||
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%4046e11a7b395f45b9837df6c6ac609004',
|
||||
releasedToStudents: false,
|
||||
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
|
||||
visibilityState: 'ready',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2023-11-09T22:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
highlights: [],
|
||||
highlightsEnabled: true,
|
||||
highlightsPreviewOnly: false,
|
||||
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||
childInfo: {
|
||||
category: 'sequential',
|
||||
displayName: 'Subsection',
|
||||
children: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@1945e9656cbe4abe8f2020c67e9e1f61',
|
||||
displayName: 'Subsection sub',
|
||||
category: 'sequential',
|
||||
hasChildren: true,
|
||||
editedOn: 'Aug 23, 2023 at 11:32 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Aug 23, 2023 at 11:33 UTC',
|
||||
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%401945e9656cbe4abe8f2020c67e9e1f61',
|
||||
releasedToStudents: false,
|
||||
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
|
||||
visibilityState: 'ready',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2023-11-09T22:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
hideAfterDue: false,
|
||||
isProctoredExam: false,
|
||||
wasExamEverLinkedWithExternal: false,
|
||||
onlineProctoringRules: '',
|
||||
isPracticeExam: false,
|
||||
isOnboardingExam: false,
|
||||
isTimeLimited: false,
|
||||
examReviewRules: '',
|
||||
defaultTimeLimitMinutes: null,
|
||||
proctoringExamConfigurationLink: null,
|
||||
supportsOnboarding: false,
|
||||
showReviewRules: true,
|
||||
childInfo: {
|
||||
category: 'vertical',
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@b8149aa5af944aed8eebf9c7dc9f3d0b',
|
||||
displayName: 'Unit',
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Aug 23, 2023 at 11:32 UTC',
|
||||
published: true,
|
||||
publishedOn: 'Aug 23, 2023 at 11:33 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@b8149aa5af944aed8eebf9c7dc9f3d0b',
|
||||
releasedToStudents: false,
|
||||
releaseDate: 'Nov 09, 2023 at 22:00 UTC',
|
||||
visibilityState: 'ready',
|
||||
hasExplicitStaffLock: false,
|
||||
start: '2023-11-09T22:00:00Z',
|
||||
graded: false,
|
||||
dueDate: '',
|
||||
due: null,
|
||||
relativeWeeksDue: null,
|
||||
format: null,
|
||||
courseGraders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatoryMessage: null,
|
||||
groupAccess: {},
|
||||
userPartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
showCorrectness: 'always',
|
||||
discussionEnabled: true,
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ancestorHasStaffLock: false,
|
||||
staffOnlyMessage: false,
|
||||
hasPartitionGroupComponents: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
},
|
||||
deprecatedBlocksInfo: {
|
||||
deprecatedEnabledBlockTypes: [],
|
||||
blocks: [],
|
||||
advanceSettingsUrl: '/settings/advanced/course-v1:edx+101+y76',
|
||||
},
|
||||
discussionsIncontextFeedbackUrl: '',
|
||||
discussionsIncontextLearnmoreUrl: '',
|
||||
initialState: {
|
||||
expandedLocators: [
|
||||
'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6',
|
||||
'block-v1:edx+101+y76+type@sequential+block@8a85e287e30a47e98d8c1f37f74a6a9d',
|
||||
],
|
||||
locatorToShow: 'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6',
|
||||
},
|
||||
languageCode: 'en',
|
||||
lmsLink: '//localhost:18000/courses/course-v1:edx+101+y76/jump_to/block-v1:edx+101+y76+type@course+block@course',
|
||||
mfeProctoredExamSettingsUrl: '',
|
||||
notificationDismissUrl: '',
|
||||
proctoringErrors: [],
|
||||
reindexLink: '/course/course-v1:edx+101+y76/search_reindex',
|
||||
rerunNotificationId: 2,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
courseReleaseDate: 'Set Date',
|
||||
courseStructure: {},
|
||||
deprecatedBlocksInfo: {
|
||||
deprecatedEnabledBlockTypes: [],
|
||||
blocks: [],
|
||||
advanceSettingsUrl: '/settings/advanced/course-v1:edx+101+y76',
|
||||
},
|
||||
discussionsIncontextFeedbackUrl: '',
|
||||
discussionsIncontextLearnmoreUrl: '',
|
||||
initialState: {
|
||||
expandedLocators: [
|
||||
'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6',
|
||||
'block-v1:edx+101+y76+type@sequential+block@8a85e287e30a47e98d8c1f37f74a6a9d',
|
||||
],
|
||||
locatorToShow: 'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6',
|
||||
},
|
||||
languageCode: 'en',
|
||||
lmsLink: '//localhost:18000/courses/course-v1:edx+101+y76/jump_to/block-v1:edx+101+y76+type@course+block@course',
|
||||
mfeProctoredExamSettingsUrl: '',
|
||||
notificationDismissUrl: '/course_notifications/course-v1:edx+101+y76/2',
|
||||
proctoringErrors: [],
|
||||
reindexLink: '/course/course-v1:edx+101+y76/search_reindex',
|
||||
rerunNotificationId: 2,
|
||||
};
|
||||
93
src/course-outline/__mocks__/courseSection.js
Normal file
@@ -0,0 +1,93 @@
|
||||
module.exports = {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d0e78d363a424da6be5c22704c34f7a7',
|
||||
display_name: 'Section',
|
||||
category: 'chapter',
|
||||
has_children: true,
|
||||
edited_on: 'Nov 22, 2023 at 07:45 UTC',
|
||||
published: true,
|
||||
published_on: 'Nov 22, 2023 at 07:45 UTC',
|
||||
studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d0e78d363a424da6be5c22704c34f7a7',
|
||||
released_to_students: true,
|
||||
release_date: 'Feb 05, 2013 at 05:00 UTC',
|
||||
visibility_state: 'live',
|
||||
has_explicit_staff_lock: false,
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
graded: false,
|
||||
due_date: '',
|
||||
due: null,
|
||||
relative_weeks_due: null,
|
||||
format: null,
|
||||
course_graders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
has_changes: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatory_message: null,
|
||||
group_access: {},
|
||||
user_partitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
show_correctness: 'always',
|
||||
highlights: [],
|
||||
highlights_enabled: true,
|
||||
highlights_preview_only: false,
|
||||
highlights_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
|
||||
child_info: {
|
||||
category: 'sequential',
|
||||
display_name: 'Subsection',
|
||||
children: [],
|
||||
},
|
||||
ancestor_has_staff_lock: false,
|
||||
staff_only_message: false,
|
||||
enable_copy_paste_units: false,
|
||||
has_partition_group_components: false,
|
||||
user_partition_info: {
|
||||
selectable_partitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selected_partition_index: -1,
|
||||
selected_groups_label: '',
|
||||
},
|
||||
};
|
||||
101
src/course-outline/__mocks__/courseSubsection.js
Normal file
@@ -0,0 +1,101 @@
|
||||
module.exports = {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@b713bc2830f34f6f87554028c3068729',
|
||||
display_name: 'Subsection',
|
||||
category: 'sequential',
|
||||
has_children: true,
|
||||
edited_on: 'Dec 05, 2023 at 10:35 UTC',
|
||||
published: true,
|
||||
published_on: 'Dec 05, 2023 at 10:35 UTC',
|
||||
studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40b713bc2830f34f6f87554028c3068729',
|
||||
released_to_students: true,
|
||||
release_date: 'Feb 05, 2013 at 05:00 UTC',
|
||||
visibility_state: 'live',
|
||||
has_explicit_staff_lock: false,
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
graded: false,
|
||||
due_date: '',
|
||||
due: null,
|
||||
relative_weeks_due: null,
|
||||
format: null,
|
||||
course_graders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
has_changes: false,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatory_message: null,
|
||||
group_access: {},
|
||||
user_partitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
show_correctness: 'always',
|
||||
hide_after_due: false,
|
||||
is_proctored_exam: false,
|
||||
was_exam_ever_linked_with_external: false,
|
||||
online_proctoring_rules: '',
|
||||
is_practice_exam: false,
|
||||
is_onboarding_exam: false,
|
||||
is_time_limited: false,
|
||||
exam_review_rules: '',
|
||||
default_time_limit_minutes: null,
|
||||
proctoring_exam_configuration_link: null,
|
||||
supports_onboarding: false,
|
||||
show_review_rules: true,
|
||||
child_info: {
|
||||
category: 'vertical',
|
||||
display_name: 'Unit',
|
||||
children: [],
|
||||
},
|
||||
ancestor_has_staff_lock: false,
|
||||
staff_only_message: false,
|
||||
enable_copy_paste_units: false,
|
||||
has_partition_group_components: false,
|
||||
user_partition_info: {
|
||||
selectable_partitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selected_partition_index: -1,
|
||||
selected_groups_label: '',
|
||||
},
|
||||
};
|
||||
6
src/course-outline/__mocks__/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export { default as courseOutlineIndexMock } from './courseOutlineIndex';
|
||||
export { default as courseOutlineIndexWithoutSections } from './courseOutlineIndexWithoutSections';
|
||||
export { default as courseBestPracticesMock } from './courseBestPractices';
|
||||
export { default as courseLaunchMock } from './courseLaunch';
|
||||
export { default as courseSectionMock } from './courseSection';
|
||||
export { default as courseSubsectionMock } from './courseSubsection';
|
||||
266
src/course-outline/card-header/CardHeader.jsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Dropdown,
|
||||
Form,
|
||||
Hyperlink,
|
||||
Icon,
|
||||
IconButton,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
MoreVert as MoveVertIcon,
|
||||
EditOutline as EditIcon,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
import { useEscapeClick } from '../../hooks';
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import { scrollToElement } from '../utils';
|
||||
import CardStatus from './CardStatus';
|
||||
import messages from './messages';
|
||||
|
||||
const CardHeader = ({
|
||||
title,
|
||||
status,
|
||||
cardId,
|
||||
hasChanges,
|
||||
onClickPublish,
|
||||
onClickConfigure,
|
||||
onClickMenuButton,
|
||||
onClickEdit,
|
||||
isFormOpen,
|
||||
onEditSubmit,
|
||||
closeForm,
|
||||
isDisabledEditField,
|
||||
onClickDelete,
|
||||
onClickDuplicate,
|
||||
onClickMoveUp,
|
||||
onClickMoveDown,
|
||||
onClickCopy,
|
||||
titleComponent,
|
||||
namePrefix,
|
||||
actions,
|
||||
enableCopyPasteUnits,
|
||||
isVertical,
|
||||
isSequential,
|
||||
proctoringExamConfigurationLink,
|
||||
discussionEnabled,
|
||||
discussionsSettings,
|
||||
parentInfo,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [titleValue, setTitleValue] = useState(title);
|
||||
const cardHeaderRef = useRef(null);
|
||||
|
||||
const isDisabledPublish = (status === ITEM_BADGE_STATUS.live
|
||||
|| status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges;
|
||||
|
||||
useEffect(() => {
|
||||
const locatorId = searchParams.get('show');
|
||||
if (!locatorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cardHeaderRef.current && locatorId === cardId) {
|
||||
scrollToElement(cardHeaderRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showDiscussionsEnabledBadge = (
|
||||
isVertical
|
||||
&& !parentInfo?.isTimeLimited
|
||||
&& discussionEnabled
|
||||
&& discussionsSettings?.providerType === 'openedx'
|
||||
&& (
|
||||
discussionsSettings?.enableGradedUnits
|
||||
|| (!discussionsSettings?.enableGradedUnits && !parentInfo.graded)
|
||||
)
|
||||
);
|
||||
|
||||
useEscapeClick({
|
||||
onEscape: () => {
|
||||
setTitleValue(title);
|
||||
closeForm();
|
||||
},
|
||||
dependency: title,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="item-card-header"
|
||||
data-testid={`${namePrefix}-card-header`}
|
||||
ref={cardHeaderRef}
|
||||
>
|
||||
{isFormOpen ? (
|
||||
<Form.Group className="m-0 w-75">
|
||||
<Form.Control
|
||||
data-testid={`${namePrefix}-edit-field`}
|
||||
ref={(e) => e && e.focus()}
|
||||
value={titleValue}
|
||||
name="displayName"
|
||||
onChange={(e) => setTitleValue(e.target.value)}
|
||||
aria-label="edit field"
|
||||
onBlur={() => onEditSubmit(titleValue)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onEditSubmit(titleValue);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabledEditField}
|
||||
/>
|
||||
</Form.Group>
|
||||
) : (
|
||||
<>
|
||||
{titleComponent}
|
||||
<IconButton
|
||||
className="item-card-edit-icon"
|
||||
data-testid={`${namePrefix}-edit-button`}
|
||||
alt={intl.formatMessage(messages.altButtonEdit)}
|
||||
iconAs={EditIcon}
|
||||
onClick={onClickEdit}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto d-flex">
|
||||
{(isVertical || isSequential) && (
|
||||
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge} />
|
||||
)}
|
||||
<Dropdown data-testid={`${namePrefix}-card-header__menu`} onClick={onClickMenuButton}>
|
||||
<Dropdown.Toggle
|
||||
className="item-card-header__menu"
|
||||
id={`${namePrefix}-card-header__menu`}
|
||||
data-testid={`${namePrefix}-card-header__menu-button`}
|
||||
as={IconButton}
|
||||
src={MoveVertIcon}
|
||||
alt={`${namePrefix}-card-header__menu`}
|
||||
iconAs={Icon}
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
{isSequential && proctoringExamConfigurationLink && (
|
||||
<Dropdown.Item
|
||||
as={Hyperlink}
|
||||
target="_blank"
|
||||
destination={proctoringExamConfigurationLink}
|
||||
href={proctoringExamConfigurationLink}
|
||||
externalLinkTitle={intl.formatMessage(messages.proctoringLinkTooltip)}
|
||||
>
|
||||
{intl.formatMessage(messages.menuProctoringLinkText)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-publish-button`}
|
||||
disabled={isDisabledPublish}
|
||||
onClick={onClickPublish}
|
||||
>
|
||||
{intl.formatMessage(messages.menuPublish)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-configure-button`}
|
||||
onClick={onClickConfigure}
|
||||
>
|
||||
{intl.formatMessage(messages.menuConfigure)}
|
||||
</Dropdown.Item>
|
||||
{isVertical && enableCopyPasteUnits && (
|
||||
<Dropdown.Item onClick={onClickCopy}>
|
||||
{intl.formatMessage(messages.menuCopy)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
{actions.duplicable && (
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-duplicate-button`}
|
||||
onClick={onClickDuplicate}
|
||||
>
|
||||
{intl.formatMessage(messages.menuDuplicate)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
{actions.draggable && (
|
||||
<>
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-move-up-button`}
|
||||
onClick={onClickMoveUp}
|
||||
disabled={!actions.allowMoveUp}
|
||||
>
|
||||
{intl.formatMessage(messages.menuMoveUp)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-move-down-button`}
|
||||
onClick={onClickMoveDown}
|
||||
disabled={!actions.allowMoveDown}
|
||||
>
|
||||
{intl.formatMessage(messages.menuMoveDown)}
|
||||
</Dropdown.Item>
|
||||
</>
|
||||
)}
|
||||
{actions.deletable && (
|
||||
<Dropdown.Item
|
||||
className="border-top border-light"
|
||||
data-testid={`${namePrefix}-card-header__menu-delete-button`}
|
||||
onClick={onClickDelete}
|
||||
>
|
||||
{intl.formatMessage(messages.menuDelete)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CardHeader.defaultProps = {
|
||||
enableCopyPasteUnits: false,
|
||||
isVertical: false,
|
||||
isSequential: false,
|
||||
onClickCopy: null,
|
||||
proctoringExamConfigurationLink: null,
|
||||
discussionEnabled: false,
|
||||
discussionsSettings: {},
|
||||
parentInfo: {},
|
||||
};
|
||||
|
||||
CardHeader.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
cardId: PropTypes.string.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
onClickPublish: PropTypes.func.isRequired,
|
||||
onClickConfigure: PropTypes.func.isRequired,
|
||||
onClickMenuButton: PropTypes.func.isRequired,
|
||||
onClickEdit: PropTypes.func.isRequired,
|
||||
isFormOpen: PropTypes.bool.isRequired,
|
||||
onEditSubmit: PropTypes.func.isRequired,
|
||||
closeForm: PropTypes.func.isRequired,
|
||||
isDisabledEditField: PropTypes.bool.isRequired,
|
||||
onClickDelete: PropTypes.func.isRequired,
|
||||
onClickDuplicate: PropTypes.func.isRequired,
|
||||
onClickMoveUp: PropTypes.func.isRequired,
|
||||
onClickMoveDown: PropTypes.func.isRequired,
|
||||
onClickCopy: PropTypes.func,
|
||||
titleComponent: PropTypes.node.isRequired,
|
||||
namePrefix: PropTypes.string.isRequired,
|
||||
proctoringExamConfigurationLink: PropTypes.string,
|
||||
actions: PropTypes.shape({
|
||||
deletable: PropTypes.bool.isRequired,
|
||||
draggable: PropTypes.bool.isRequired,
|
||||
childAddable: PropTypes.bool.isRequired,
|
||||
duplicable: PropTypes.bool.isRequired,
|
||||
allowMoveUp: PropTypes.bool,
|
||||
allowMoveDown: PropTypes.bool,
|
||||
}).isRequired,
|
||||
enableCopyPasteUnits: PropTypes.bool,
|
||||
isVertical: PropTypes.bool,
|
||||
isSequential: PropTypes.bool,
|
||||
discussionEnabled: PropTypes.bool,
|
||||
discussionsSettings: PropTypes.shape({
|
||||
providerType: PropTypes.string,
|
||||
enableGradedUnits: PropTypes.bool,
|
||||
}),
|
||||
parentInfo: PropTypes.shape({
|
||||
isTimeLimited: PropTypes.bool,
|
||||
graded: PropTypes.bool,
|
||||
}),
|
||||
};
|
||||
|
||||
export default CardHeader;
|
||||
29
src/course-outline/card-header/CardHeader.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
.item-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.item-card-header__title-btn {
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
width: fit-content;
|
||||
height: 1.5rem;
|
||||
margin-right: .25rem;
|
||||
background: transparent;
|
||||
color: $black;
|
||||
}
|
||||
|
||||
.item-card-edit-icon {
|
||||
opacity: 0;
|
||||
transition: opacity .3s linear;
|
||||
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.item-card-edit-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
254
src/course-outline/card-header/CardHeader.test.jsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import {
|
||||
act, render, fireEvent, waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import CardHeader from './CardHeader';
|
||||
import TitleButton from './TitleButton';
|
||||
import messages from './messages';
|
||||
|
||||
const onExpandMock = jest.fn();
|
||||
const onClickMenuButtonMock = jest.fn();
|
||||
const onClickPublishMock = jest.fn();
|
||||
const onClickEditMock = jest.fn();
|
||||
const onClickDeleteMock = jest.fn();
|
||||
const onClickDuplicateMock = jest.fn();
|
||||
const onClickConfigureMock = jest.fn();
|
||||
const onClickMoveUpMock = jest.fn();
|
||||
const onClickMoveDownMock = jest.fn();
|
||||
const closeFormMock = jest.fn();
|
||||
|
||||
const cardHeaderProps = {
|
||||
title: 'Some title',
|
||||
status: ITEM_BADGE_STATUS.live,
|
||||
cardId: '12345',
|
||||
hasChanges: false,
|
||||
onClickMenuButton: onClickMenuButtonMock,
|
||||
onClickPublish: onClickPublishMock,
|
||||
onClickEdit: onClickEditMock,
|
||||
isFormOpen: false,
|
||||
onEditSubmit: jest.fn(),
|
||||
closeForm: closeFormMock,
|
||||
isDisabledEditField: false,
|
||||
onClickDelete: onClickDeleteMock,
|
||||
onClickDuplicate: onClickDuplicateMock,
|
||||
onClickConfigure: onClickConfigureMock,
|
||||
onClickMoveUp: onClickMoveUpMock,
|
||||
onClickMoveDown: onClickMoveDownMock,
|
||||
isSequential: true,
|
||||
namePrefix: 'subsection',
|
||||
actions: {
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
};
|
||||
|
||||
const renderComponent = (props, entry = '/') => {
|
||||
const titleComponent = (
|
||||
<TitleButton
|
||||
isExpanded
|
||||
title={cardHeaderProps.title}
|
||||
onTitleClick={onExpandMock}
|
||||
namePrefix={cardHeaderProps.namePrefix}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<CardHeader
|
||||
{...cardHeaderProps}
|
||||
titleComponent={titleComponent}
|
||||
{...props}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<CardHeader />', () => {
|
||||
it('render CardHeader component correctly', async () => {
|
||||
const { findByText, findByTestId, queryByTestId } = renderComponent();
|
||||
|
||||
expect(await findByText(cardHeaderProps.title)).toBeInTheDocument();
|
||||
expect(await findByTestId('subsection-card-header__expanded-btn')).toBeInTheDocument();
|
||||
expect(await findByTestId('subsection-card-header__menu')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('edit field')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('render status badge as live', async () => {
|
||||
const { findByText } = renderComponent();
|
||||
expect(await findByText(messages.statusBadgeLive.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render status badge as published_not_live', async () => {
|
||||
const { findByText } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.publishedNotLive,
|
||||
});
|
||||
|
||||
expect(await findByText(messages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render status badge as staff_only', async () => {
|
||||
const { findByText } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.staffOnly,
|
||||
});
|
||||
|
||||
expect(await findByText(messages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render status badge as draft', async () => {
|
||||
const { findByText } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.draft,
|
||||
});
|
||||
|
||||
expect(await findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check publish menu item is disabled when subsection status is live or published not live and it has no changes', async () => {
|
||||
const { findByText, findByTestId } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.publishedNotLive,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
expect(await findByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('check publish menu item is enabled when subsection status is live or published not live and it has changes', async () => {
|
||||
const { findByText, findByTestId } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.publishedNotLive,
|
||||
hasChanges: true,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
expect(await findByText(messages.menuPublish.defaultMessage)).not.toHaveAttribute('aria-disabled');
|
||||
});
|
||||
|
||||
it('calls handleExpanded when button is clicked', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
|
||||
const expandButton = await findByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandButton);
|
||||
expect(onExpandMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClickMenuButton when menu is clicked', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
expect(onClickMenuButtonMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClickPublish when item is clicked', async () => {
|
||||
const { findByText, findByTestId } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.draft,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const publishMenuItem = await findByText(messages.menuPublish.defaultMessage);
|
||||
await act(async () => fireEvent.click(publishMenuItem));
|
||||
expect(onClickPublishMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClickEdit when the button is clicked', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
|
||||
const editButton = await findByTestId('subsection-edit-button');
|
||||
await act(async () => fireEvent.click(editButton));
|
||||
expect(onClickEditMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('check is field visible when isFormOpen is true', async () => {
|
||||
const { findByTestId, queryByTestId } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
isFormOpen: true,
|
||||
});
|
||||
|
||||
expect(await findByTestId('subsection-edit-field')).toBeInTheDocument();
|
||||
waitFor(() => {
|
||||
expect(queryByTestId('subsection-card-header__expanded-btn')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('edit-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('check is field disabled when isDisabledEditField is true', async () => {
|
||||
const { findByTestId } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
isFormOpen: true,
|
||||
isDisabledEditField: true,
|
||||
});
|
||||
|
||||
expect(await findByTestId('subsection-edit-field')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls onClickDelete when item is clicked', async () => {
|
||||
const { findByText, findByTestId } = renderComponent();
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
const deleteMenuItem = await findByText(messages.menuDelete.defaultMessage);
|
||||
await act(async () => fireEvent.click(deleteMenuItem));
|
||||
expect(onClickDeleteMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClickDuplicate when item is clicked', async () => {
|
||||
const { findByText, findByTestId } = renderComponent();
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const duplicateMenuItem = await findByText(messages.menuDuplicate.defaultMessage);
|
||||
fireEvent.click(duplicateMenuItem);
|
||||
await act(async () => fireEvent.click(duplicateMenuItem));
|
||||
expect(onClickDuplicateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('check if proctoringExamConfigurationLink is visible', async () => {
|
||||
const { findByText, findByTestId } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
proctoringExamConfigurationLink: 'https://localhost:8000/',
|
||||
isSequential: true,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
|
||||
expect(await findByText(messages.menuProctoringLinkText.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check if discussion enabled badge is visible', async () => {
|
||||
const { queryByText } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
isVertical: true,
|
||||
discussionEnabled: true,
|
||||
discussionsSettings: {
|
||||
providerType: 'openedx',
|
||||
enableGradedUnits: true,
|
||||
},
|
||||
parentInfo: {
|
||||
isTimeLimited: false,
|
||||
graded: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
40
src/course-outline/card-header/CardStatus.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import classNames from 'classnames';
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import { getItemStatusBadgeContent } from '../utils';
|
||||
import messages from './messages';
|
||||
import StatusBadge from './StatusBadge';
|
||||
|
||||
const CardStatus = ({
|
||||
status,
|
||||
showDiscussionsEnabledBadge,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDiscussionsEnabledBadge && (
|
||||
<StatusBadge
|
||||
text={intl.formatMessage(messages.discussionEnabledBadgeText)}
|
||||
/>
|
||||
)}
|
||||
{badgeTitle && (
|
||||
<StatusBadge
|
||||
text={badgeTitle}
|
||||
icon={badgeIcon}
|
||||
iconClassName={classNames({ 'text-success-500': status === ITEM_BADGE_STATUS.live })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CardStatus.propTypes = {
|
||||
status: PropTypes.string.isRequired,
|
||||
showDiscussionsEnabledBadge: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default CardStatus;
|
||||
42
src/course-outline/card-header/StatusBadge.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Icon } from '@edx/paragon';
|
||||
|
||||
const StatusBadge = ({
|
||||
text,
|
||||
icon,
|
||||
iconClassName,
|
||||
}) => {
|
||||
if (text) {
|
||||
return (
|
||||
<div
|
||||
className="px-2 py-1 mr-2 rounded bg-white align-self-center align-items-center d-flex border border-light-300"
|
||||
role="status"
|
||||
>
|
||||
{icon && (
|
||||
<Icon
|
||||
src={icon}
|
||||
size="sm"
|
||||
className={iconClassName}
|
||||
/>
|
||||
)}
|
||||
<span className="small ml-1">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
StatusBadge.defaultProps = {
|
||||
text: '',
|
||||
icon: '',
|
||||
iconClassName: '',
|
||||
};
|
||||
|
||||
StatusBadge.propTypes = {
|
||||
text: PropTypes.string,
|
||||
icon: PropTypes.string,
|
||||
iconClassName: PropTypes.string,
|
||||
};
|
||||
|
||||
export default StatusBadge;
|
||||
55
src/course-outline/card-header/TitleButton.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button,
|
||||
OverlayTrigger,
|
||||
Tooltip,
|
||||
Truncate,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
ArrowDropDown as ArrowDownIcon,
|
||||
ArrowDropUp as ArrowUpIcon,
|
||||
} from '@edx/paragon/icons';
|
||||
import messages from './messages';
|
||||
|
||||
const TitleButton = ({
|
||||
title,
|
||||
isExpanded,
|
||||
onTitleClick,
|
||||
namePrefix,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const titleTooltipMessage = intl.formatMessage(messages.expandTooltip);
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
placement="bottom"
|
||||
overlay={(
|
||||
<Tooltip
|
||||
id={`${title}-${titleTooltipMessage}`}
|
||||
>
|
||||
{titleTooltipMessage}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
iconBefore={isExpanded ? ArrowUpIcon : ArrowDownIcon}
|
||||
variant="tertiary"
|
||||
data-testid={`${namePrefix}-card-header__expanded-btn`}
|
||||
className="item-card-header__title-btn"
|
||||
onClick={onTitleClick}
|
||||
>
|
||||
<Truncate lines={1} className={`${namePrefix}-card-title mb-0`}>{title}</Truncate>
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
TitleButton.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
isExpanded: PropTypes.bool.isRequired,
|
||||
onTitleClick: PropTypes.func.isRequired,
|
||||
namePrefix: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default TitleButton;
|
||||
27
src/course-outline/card-header/TitleLink.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Truncate } from '@edx/paragon';
|
||||
|
||||
const TitleLink = ({
|
||||
title,
|
||||
titleLink,
|
||||
namePrefix,
|
||||
}) => (
|
||||
<Button
|
||||
as={Link}
|
||||
variant="tertiary"
|
||||
data-testid={`${namePrefix}-card-header__title-link`}
|
||||
className="item-card-header__title-btn"
|
||||
to={titleLink}
|
||||
>
|
||||
<Truncate lines={1} className={`${namePrefix}-card-title mb-0`}>{title}</Truncate>
|
||||
</Button>
|
||||
);
|
||||
|
||||
TitleLink.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
titleLink: PropTypes.string.isRequired,
|
||||
namePrefix: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default TitleLink;
|
||||
78
src/course-outline/card-header/messages.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
expandTooltip: {
|
||||
id: 'course-authoring.course-outline.card.expandTooltip',
|
||||
defaultMessage: 'Collapse/Expand this card',
|
||||
},
|
||||
statusBadgeLive: {
|
||||
id: 'course-authoring.course-outline.card.status-badge.live',
|
||||
defaultMessage: 'Live',
|
||||
},
|
||||
statusBadgeGated: {
|
||||
id: 'course-authoring.course-outline.card.status-badge.gated',
|
||||
defaultMessage: 'Gated',
|
||||
},
|
||||
statusBadgePublishedNotLive: {
|
||||
id: 'course-authoring.course-outline.card.status-badge.published-not-live',
|
||||
defaultMessage: 'Published not live',
|
||||
},
|
||||
statusBadgeStaffOnly: {
|
||||
id: 'course-authoring.course-outline.card.status-badge.staff-only',
|
||||
defaultMessage: 'Staff only',
|
||||
},
|
||||
statusBadgeDraft: {
|
||||
id: 'course-authoring.course-outline.card.status-badge.draft',
|
||||
defaultMessage: 'Draft',
|
||||
},
|
||||
statusBadgeUnpublishedChanges: {
|
||||
id: 'course-authoring.course-outline.card.status-badge.draft-unpublished-changes',
|
||||
defaultMessage: 'Draft (Unpublished changes)',
|
||||
},
|
||||
altButtonEdit: {
|
||||
id: 'course-authoring.course-outline.card.button.edit.alt',
|
||||
defaultMessage: 'Edit',
|
||||
},
|
||||
menuPublish: {
|
||||
id: 'course-authoring.course-outline.card.menu.publish',
|
||||
defaultMessage: 'Publish',
|
||||
},
|
||||
menuConfigure: {
|
||||
id: 'course-authoring.course-outline.card.menu.configure',
|
||||
defaultMessage: 'Configure',
|
||||
},
|
||||
menuDuplicate: {
|
||||
id: 'course-authoring.course-outline.card.menu.duplicate',
|
||||
defaultMessage: 'Duplicate',
|
||||
},
|
||||
menuMoveUp: {
|
||||
id: 'course-authoring.course-outline.card.menu.moveup',
|
||||
defaultMessage: 'Move up',
|
||||
},
|
||||
menuMoveDown: {
|
||||
id: 'course-authoring.course-outline.card.menu.movedown',
|
||||
defaultMessage: 'Move down',
|
||||
},
|
||||
menuDelete: {
|
||||
id: 'course-authoring.course-outline.card.menu.delete',
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
menuCopy: {
|
||||
id: 'course-authoring.course-outline.card.menu.delete',
|
||||
defaultMessage: 'Copy to clipboard',
|
||||
},
|
||||
menuProctoringLinkText: {
|
||||
id: 'course-authoring.course-outline.card.menu.proctoring-settings',
|
||||
defaultMessage: 'Proctoring settings',
|
||||
},
|
||||
proctoringLinkTooltip: {
|
||||
id: 'course-authoring.course-outline.card.menu.proctoring-settings-tooltip',
|
||||
defaultMessage: 'Proctoring settings',
|
||||
},
|
||||
discussionEnabledBadgeText: {
|
||||
id: 'course-authoring.course-outline.card.badge.discussionEnabled',
|
||||
defaultMessage: 'Discussions enabled',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
276
src/course-outline/configure-modal/AdvancedTab.jsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
import { Alert, Form, Hyperlink } from '@edx/paragon';
|
||||
import {
|
||||
Warning as WarningIcon,
|
||||
} from '@edx/paragon/icons';
|
||||
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
import PrereqSettings from './PrereqSettings';
|
||||
|
||||
const AdvancedTab = ({
|
||||
values,
|
||||
setFieldValue,
|
||||
prereqs,
|
||||
releasedToStudents,
|
||||
wasExamEverLinkedWithExternal,
|
||||
enableProctoredExams,
|
||||
supportsOnboarding,
|
||||
wasProctoredExam,
|
||||
showReviewRules,
|
||||
onlineProctoringRules,
|
||||
}) => {
|
||||
const {
|
||||
isTimeLimited,
|
||||
isProctoredExam,
|
||||
isOnboardingExam,
|
||||
isPracticeExam,
|
||||
defaultTimeLimitMinutes,
|
||||
examReviewRules,
|
||||
} = values;
|
||||
let examTypeValue = 'none';
|
||||
|
||||
if (isTimeLimited && isProctoredExam) {
|
||||
if (isOnboardingExam) {
|
||||
examTypeValue = 'onboardingExam';
|
||||
} else if (isPracticeExam) {
|
||||
examTypeValue = 'practiceExam';
|
||||
} else {
|
||||
examTypeValue = 'proctoredExam';
|
||||
}
|
||||
} else if (isTimeLimited) {
|
||||
examTypeValue = 'timed';
|
||||
}
|
||||
|
||||
const formatHour = (hour) => {
|
||||
const hh = Math.floor(hour / 60);
|
||||
const mm = hour % 60;
|
||||
let hhs = `${hh}`;
|
||||
let mms = `${mm}`;
|
||||
if (hh < 10) {
|
||||
hhs = `0${hh}`;
|
||||
}
|
||||
if (mm < 10) {
|
||||
mms = `0${mm}`;
|
||||
}
|
||||
if (Number.isNaN(hh)) {
|
||||
hhs = '00';
|
||||
}
|
||||
if (Number.isNaN(mm)) {
|
||||
mms = '00';
|
||||
}
|
||||
return `${hhs}:${mms}`;
|
||||
};
|
||||
|
||||
const [timeLimit, setTimeLimit] = useState(formatHour(defaultTimeLimitMinutes));
|
||||
const showReviewRulesDiv = showReviewRules && isProctoredExam && !isPracticeExam && !isOnboardingExam;
|
||||
|
||||
const handleChange = (e) => {
|
||||
if (e.target.value === 'timed') {
|
||||
setFieldValue('isTimeLimited', true);
|
||||
setFieldValue('isOnboardingExam', false);
|
||||
setFieldValue('isPracticeExam', false);
|
||||
setFieldValue('isProctoredExam', false);
|
||||
} else if (e.target.value === 'onboardingExam') {
|
||||
setFieldValue('isOnboardingExam', true);
|
||||
setFieldValue('isProctoredExam', true);
|
||||
setFieldValue('isTimeLimited', true);
|
||||
setFieldValue('isPracticeExam', false);
|
||||
} else if (e.target.value === 'practiceExam') {
|
||||
setFieldValue('isPracticeExam', true);
|
||||
setFieldValue('isProctoredExam', true);
|
||||
setFieldValue('isTimeLimited', true);
|
||||
setFieldValue('isOnboardingExam', false);
|
||||
} else if (e.target.value === 'proctoredExam') {
|
||||
setFieldValue('isProctoredExam', true);
|
||||
setFieldValue('isTimeLimited', true);
|
||||
setFieldValue('isOnboardingExam', false);
|
||||
setFieldValue('isPracticeExam', false);
|
||||
} else {
|
||||
setFieldValue('isTimeLimited', false);
|
||||
setFieldValue('isOnboardingExam', false);
|
||||
setFieldValue('isPracticeExam', false);
|
||||
setFieldValue('isProctoredExam', false);
|
||||
}
|
||||
};
|
||||
|
||||
const setCurrentTimeLimit = (event) => {
|
||||
const { validity: { valid } } = event.target;
|
||||
let { value } = event.target;
|
||||
value = value.trim();
|
||||
if (value && valid) {
|
||||
const minutes = moment.duration(value).asMinutes();
|
||||
setFieldValue('defaultTimeLimitMinutes', minutes);
|
||||
}
|
||||
setTimeLimit(value);
|
||||
};
|
||||
|
||||
const renderAlerts = () => {
|
||||
const proctoredExamLockedIn = releasedToStudents && wasExamEverLinkedWithExternal;
|
||||
return (
|
||||
<>
|
||||
{proctoredExamLockedIn && !wasProctoredExam && (
|
||||
<Alert variant="warning" icon={WarningIcon}>
|
||||
<FormattedMessage {...messages.proctoredExamLockedAndisNotProctoredExamAlert} />
|
||||
</Alert>
|
||||
)}
|
||||
{proctoredExamLockedIn && wasProctoredExam && (
|
||||
<Alert variant="warning" icon={WarningIcon}>
|
||||
<FormattedMessage {...messages.proctoredExamLockedAndisProctoredExamAlert} />
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.setSpecialExam} /></h5>
|
||||
<hr />
|
||||
<Form.RadioSet
|
||||
name="specialExam"
|
||||
onChange={handleChange}
|
||||
value={examTypeValue}
|
||||
>
|
||||
{renderAlerts()}
|
||||
<Form.Radio value="none">
|
||||
<FormattedMessage {...messages.none} />
|
||||
</Form.Radio>
|
||||
<Form.Radio
|
||||
value="timed"
|
||||
description={<FormattedMessage {...messages.timedDescription} />}
|
||||
controlClassName="mw-1-25rem"
|
||||
>
|
||||
<FormattedMessage {...messages.timed} />
|
||||
</Form.Radio>
|
||||
{enableProctoredExams && (
|
||||
<>
|
||||
<Form.Radio
|
||||
value="proctoredExam"
|
||||
description={<FormattedMessage {...messages.proctoredExamDescription} />}
|
||||
controlClassName="mw-1-25rem"
|
||||
>
|
||||
<FormattedMessage {...messages.proctoredExam} />
|
||||
</Form.Radio>
|
||||
{supportsOnboarding ? (
|
||||
<Form.Radio
|
||||
description={<FormattedMessage {...messages.onboardingExamDescription} />}
|
||||
value="onboardingExam"
|
||||
controlClassName="mw-1-25rem"
|
||||
>
|
||||
<FormattedMessage {...messages.onboardingExam} />
|
||||
</Form.Radio>
|
||||
) : (
|
||||
<Form.Radio
|
||||
value="practiceExam"
|
||||
controlClassName="mw-1-25rem"
|
||||
description={<FormattedMessage {...messages.practiceExamDescription} />}
|
||||
>
|
||||
<FormattedMessage {...messages.practiceExam} />
|
||||
</Form.Radio>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form.RadioSet>
|
||||
{ isTimeLimited && (
|
||||
<div className="mt-3" data-testid="advanced-tab-hours-picker-wrapper">
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<FormattedMessage {...messages.timeAllotted} />
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
onChange={setCurrentTimeLimit}
|
||||
value={timeLimit}
|
||||
placeholder="HH:MM"
|
||||
pattern="^[0-9][0-9]:[0-5][0-9]$"
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Text><FormattedMessage {...messages.timeLimitDescription} /></Form.Text>
|
||||
</div>
|
||||
)}
|
||||
{ showReviewRulesDiv && (
|
||||
<div className="mt-3">
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<FormattedMessage {...messages.reviewRulesLabel} />
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
onChange={(e) => setFieldValue('examReviewRules', e.target.value)}
|
||||
value={examReviewRules}
|
||||
as="textarea"
|
||||
rows="3"
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Text>
|
||||
{ onlineProctoringRules ? (
|
||||
<FormattedMessage
|
||||
{...messages.reviewRulesDescriptionWithLink}
|
||||
values={{
|
||||
hyperlink: (
|
||||
<Hyperlink
|
||||
destination={onlineProctoringRules}
|
||||
target="_blank"
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
<FormattedMessage
|
||||
{...messages.reviewRulesDescriptionLinkText}
|
||||
/>
|
||||
</Hyperlink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage {...messages.reviewRulesDescription} />
|
||||
)}
|
||||
</Form.Text>
|
||||
</div>
|
||||
)}
|
||||
<PrereqSettings
|
||||
values={values}
|
||||
setFieldValue={setFieldValue}
|
||||
prereqs={prereqs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AdvancedTab.defaultProps = {
|
||||
prereqs: [],
|
||||
wasExamEverLinkedWithExternal: false,
|
||||
enableProctoredExams: false,
|
||||
supportsOnboarding: false,
|
||||
wasProctoredExam: false,
|
||||
showReviewRules: false,
|
||||
onlineProctoringRules: '',
|
||||
};
|
||||
|
||||
AdvancedTab.propTypes = {
|
||||
values: PropTypes.shape({
|
||||
isTimeLimited: PropTypes.bool.isRequired,
|
||||
defaultTimeLimitMinutes: PropTypes.number,
|
||||
isPrereq: PropTypes.bool,
|
||||
prereqUsageKey: PropTypes.string,
|
||||
prereqMinScore: PropTypes.number,
|
||||
prereqMinCompletion: PropTypes.number,
|
||||
isProctoredExam: PropTypes.bool,
|
||||
isPracticeExam: PropTypes.bool,
|
||||
isOnboardingExam: PropTypes.bool,
|
||||
examReviewRules: PropTypes.string,
|
||||
}).isRequired,
|
||||
setFieldValue: PropTypes.func.isRequired,
|
||||
prereqs: PropTypes.arrayOf(PropTypes.shape({
|
||||
blockUsageKey: PropTypes.string.isRequired,
|
||||
blockDisplayName: PropTypes.string.isRequired,
|
||||
})),
|
||||
releasedToStudents: PropTypes.bool.isRequired,
|
||||
wasExamEverLinkedWithExternal: PropTypes.bool,
|
||||
enableProctoredExams: PropTypes.bool,
|
||||
supportsOnboarding: PropTypes.bool,
|
||||
wasProctoredExam: PropTypes.bool,
|
||||
showReviewRules: PropTypes.bool,
|
||||
onlineProctoringRules: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(AdvancedTab);
|
||||
106
src/course-outline/configure-modal/BasicTab.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Stack, Form } from '@edx/paragon';
|
||||
import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
import { DatepickerControl, DATEPICKER_TYPES } from '../../generic/datepicker-control';
|
||||
|
||||
const BasicTab = ({
|
||||
values,
|
||||
setFieldValue,
|
||||
courseGraders,
|
||||
isSubsection,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
releaseDate,
|
||||
graderType,
|
||||
dueDate,
|
||||
} = values;
|
||||
|
||||
const onChangeGraderType = (e) => setFieldValue('graderType', e.target.value);
|
||||
|
||||
const createOptions = () => courseGraders.map((option) => (
|
||||
<option key={option} value={option}> {option} </option>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.releaseDateAndTime} /></h5>
|
||||
<hr />
|
||||
<div data-testid="release-date-stack">
|
||||
<Stack className="mt-3" direction="horizontal" gap={5}>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.date}
|
||||
value={releaseDate}
|
||||
label={intl.formatMessage(messages.releaseDate)}
|
||||
controlName="state-date"
|
||||
onChange={(val) => setFieldValue('releaseDate', val)}
|
||||
/>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.time}
|
||||
value={releaseDate}
|
||||
label={intl.formatMessage(messages.releaseTimeUTC)}
|
||||
controlName="start-time"
|
||||
onChange={(val) => setFieldValue('releaseDate', val)}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
{
|
||||
isSubsection && (
|
||||
<div>
|
||||
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.grading} /></h5>
|
||||
<hr />
|
||||
<Form.Group>
|
||||
<Form.Label><FormattedMessage {...messages.gradeAs} /></Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
defaultValue={graderType}
|
||||
onChange={onChangeGraderType}
|
||||
data-testid="grader-type-select"
|
||||
>
|
||||
<option key="notgraded" value="notgraded">
|
||||
{intl.formatMessage(messages.notGradedTypeOption)}
|
||||
</option>
|
||||
{createOptions()}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
<div data-testid="due-date-stack">
|
||||
<Stack className="mt-3" direction="horizontal" gap={5}>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.date}
|
||||
value={dueDate}
|
||||
label={intl.formatMessage(messages.dueDate)}
|
||||
controlName="state-date"
|
||||
onChange={(val) => setFieldValue('dueDate', val)}
|
||||
data-testid="due-date-picker"
|
||||
/>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.time}
|
||||
value={dueDate}
|
||||
label={intl.formatMessage(messages.dueTimeUTC)}
|
||||
controlName="start-time"
|
||||
onChange={(val) => setFieldValue('dueDate', val)}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
BasicTab.propTypes = {
|
||||
isSubsection: PropTypes.bool.isRequired,
|
||||
values: PropTypes.shape({
|
||||
releaseDate: PropTypes.string.isRequired,
|
||||
graderType: PropTypes.string.isRequired,
|
||||
dueDate: PropTypes.string,
|
||||
}).isRequired,
|
||||
courseGraders: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
setFieldValue: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(BasicTab);
|
||||
303
src/course-outline/configure-modal/ConfigureModal.jsx
Normal file
@@ -0,0 +1,303 @@
|
||||
/* eslint-disable import/named */
|
||||
import React from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ModalDialog,
|
||||
Button,
|
||||
ActionRow,
|
||||
Form,
|
||||
Tab,
|
||||
Tabs,
|
||||
} from '@edx/paragon';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Formik } from 'formik';
|
||||
|
||||
import { VisibilityTypes } from '../../data/constants';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import { getCurrentItem, getProctoredExamsFlag } from '../data/selectors';
|
||||
import messages from './messages';
|
||||
import BasicTab from './BasicTab';
|
||||
import VisibilityTab from './VisibilityTab';
|
||||
import AdvancedTab from './AdvancedTab';
|
||||
import UnitTab from './UnitTab';
|
||||
|
||||
const ConfigureModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfigureSubmit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
displayName,
|
||||
start: sectionStartDate,
|
||||
visibilityState,
|
||||
due,
|
||||
isTimeLimited,
|
||||
defaultTimeLimitMinutes,
|
||||
hideAfterDue,
|
||||
showCorrectness,
|
||||
courseGraders,
|
||||
category,
|
||||
format,
|
||||
userPartitionInfo,
|
||||
ancestorHasStaffLock,
|
||||
isPrereq,
|
||||
prereqs,
|
||||
prereq,
|
||||
prereqMinScore,
|
||||
prereqMinCompletion,
|
||||
releasedToStudents,
|
||||
wasExamEverLinkedWithExternal,
|
||||
isProctoredExam,
|
||||
isOnboardingExam,
|
||||
isPracticeExam,
|
||||
examReviewRules,
|
||||
supportsOnboarding,
|
||||
showReviewRules,
|
||||
onlineProctoringRules,
|
||||
} = useSelector(getCurrentItem);
|
||||
const enableProctoredExams = useSelector(getProctoredExamsFlag);
|
||||
|
||||
const getSelectedGroups = () => {
|
||||
if (userPartitionInfo?.selectedPartitionIndex >= 0) {
|
||||
return userPartitionInfo?.selectablePartitions[userPartitionInfo?.selectedPartitionIndex]
|
||||
?.groups
|
||||
.filter(({ selected }) => selected)
|
||||
.map(({ id }) => `${id}`)
|
||||
|| [];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const defaultPrereqScore = (val) => {
|
||||
if (val === null || val === undefined) {
|
||||
return 100;
|
||||
}
|
||||
return parseFloat(val);
|
||||
};
|
||||
|
||||
const initialValues = {
|
||||
releaseDate: sectionStartDate,
|
||||
isVisibleToStaffOnly: visibilityState === VisibilityTypes.STAFF_ONLY,
|
||||
saveButtonDisabled: true,
|
||||
graderType: format == null ? 'notgraded' : format,
|
||||
dueDate: due == null ? '' : due,
|
||||
isTimeLimited,
|
||||
isProctoredExam,
|
||||
isOnboardingExam,
|
||||
isPracticeExam,
|
||||
examReviewRules,
|
||||
defaultTimeLimitMinutes,
|
||||
hideAfterDue: hideAfterDue === undefined ? false : hideAfterDue,
|
||||
showCorrectness,
|
||||
isPrereq,
|
||||
prereqUsageKey: prereq,
|
||||
prereqMinScore: defaultPrereqScore(prereqMinScore),
|
||||
prereqMinCompletion: defaultPrereqScore(prereqMinCompletion),
|
||||
// by default it is -1 i.e. accessible to all learners & staff
|
||||
selectedPartitionIndex: userPartitionInfo?.selectedPartitionIndex,
|
||||
selectedGroups: getSelectedGroups(),
|
||||
};
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
isTimeLimited: Yup.boolean(),
|
||||
isProctoredExam: Yup.boolean(),
|
||||
isPracticeExam: Yup.boolean(),
|
||||
isOnboardingExam: Yup.boolean(),
|
||||
examReviewRules: Yup.string(),
|
||||
defaultTimeLimitMinutes: Yup.number().nullable(true),
|
||||
hideAfterDueState: Yup.boolean(),
|
||||
showCorrectness: Yup.string().required(),
|
||||
isPrereq: Yup.boolean(),
|
||||
prereqUsageKey: Yup.string().nullable(true),
|
||||
prereqMinScore: Yup.number().min(
|
||||
0,
|
||||
intl.formatMessage(messages.minScoreError),
|
||||
).max(
|
||||
100,
|
||||
intl.formatMessage(messages.minScoreError),
|
||||
).nullable(true),
|
||||
prereqMinCompletion: Yup.number().min(
|
||||
0,
|
||||
intl.formatMessage(messages.minScoreError),
|
||||
).max(
|
||||
100,
|
||||
intl.formatMessage(messages.minScoreError),
|
||||
).nullable(true),
|
||||
selectedPartitionIndex: Yup.number().integer(),
|
||||
selectedGroups: Yup.array().of(Yup.string()),
|
||||
});
|
||||
|
||||
const isSubsection = category === COURSE_BLOCK_NAMES.sequential.id;
|
||||
|
||||
const handleSave = (data) => {
|
||||
const groupAccess = {};
|
||||
switch (category) {
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
onConfigureSubmit(data.isVisibleToStaffOnly, data.releaseDate);
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
onConfigureSubmit(
|
||||
data.isVisibleToStaffOnly,
|
||||
data.releaseDate,
|
||||
data.graderType,
|
||||
data.dueDate,
|
||||
data.isTimeLimited,
|
||||
data.isProctoredExam,
|
||||
data.isOnboardingExam,
|
||||
data.isPracticeExam,
|
||||
data.examReviewRules,
|
||||
data.isTimeLimited ? data.defaultTimeLimitMinutes : 0,
|
||||
data.hideAfterDue,
|
||||
data.showCorrectness,
|
||||
data.isPrereq,
|
||||
data.prereqUsageKey,
|
||||
data.prereqMinScore,
|
||||
data.prereqMinCompletion,
|
||||
);
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
// groupAccess should be {partitionId: [group1, group2]} or {} if selectedPartitionIndex === -1
|
||||
if (data.selectedPartitionIndex >= 0) {
|
||||
const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id;
|
||||
groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10));
|
||||
}
|
||||
onConfigureSubmit(data.isVisibleToStaffOnly, groupAccess);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const renderModalBody = (values, setFieldValue) => {
|
||||
switch (category) {
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
return (
|
||||
<Tabs>
|
||||
<Tab eventKey="basic" title={intl.formatMessage(messages.basicTabTitle)}>
|
||||
<BasicTab
|
||||
values={values}
|
||||
setFieldValue={setFieldValue}
|
||||
isSubsection={isSubsection}
|
||||
courseGraders={courseGraders === 'undefined' ? [] : courseGraders}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab eventKey="visibility" title={intl.formatMessage(messages.visibilityTabTitle)}>
|
||||
<VisibilityTab
|
||||
values={values}
|
||||
setFieldValue={setFieldValue}
|
||||
category={category}
|
||||
isSubsection={isSubsection}
|
||||
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
);
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
return (
|
||||
<Tabs>
|
||||
<Tab eventKey="basic" title={intl.formatMessage(messages.basicTabTitle)}>
|
||||
<BasicTab
|
||||
values={values}
|
||||
setFieldValue={setFieldValue}
|
||||
isSubsection={isSubsection}
|
||||
courseGraders={courseGraders === 'undefined' ? [] : courseGraders}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab eventKey="visibility" title={intl.formatMessage(messages.visibilityTabTitle)}>
|
||||
<VisibilityTab
|
||||
values={values}
|
||||
setFieldValue={setFieldValue}
|
||||
category={category}
|
||||
isSubsection={isSubsection}
|
||||
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab eventKey="advanced" title={intl.formatMessage(messages.advancedTabTitle)}>
|
||||
<AdvancedTab
|
||||
values={values}
|
||||
setFieldValue={setFieldValue}
|
||||
prereqs={prereqs}
|
||||
releasedToStudents={releasedToStudents}
|
||||
wasExamEverLinkedWithExternal={wasExamEverLinkedWithExternal}
|
||||
enableProctoredExams={enableProctoredExams}
|
||||
supportsOnboarding={supportsOnboarding}
|
||||
showReviewRules={showReviewRules}
|
||||
wasProctoredExam={isProctoredExam}
|
||||
onlineProctoringRules={onlineProctoringRules}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
);
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
return (
|
||||
<UnitTab
|
||||
values={values}
|
||||
setFieldValue={setFieldValue}
|
||||
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY && !ancestorHasStaffLock}
|
||||
userPartitionInfo={userPartitionInfo}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
className="configure-modal"
|
||||
size="lg"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
<div data-testid="configure-modal">
|
||||
<ModalDialog.Header className="configure-modal__header">
|
||||
<ModalDialog.Title>
|
||||
{intl.formatMessage(messages.title, { title: displayName })}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSave}
|
||||
validationSchema={validationSchema}
|
||||
validateOnBlur
|
||||
validateOnChange
|
||||
>
|
||||
{({
|
||||
values, handleSubmit, dirty, isValid, setFieldValue,
|
||||
}) => (
|
||||
<>
|
||||
<ModalDialog.Body className="configure-modal__body">
|
||||
<Form.Group size="sm" className="form-field">
|
||||
{renderModalBody(values, setFieldValue)}
|
||||
</Form.Group>
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer className="pt-1">
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{intl.formatMessage(messages.cancelButton)}
|
||||
</ModalDialog.CloseButton>
|
||||
<Button data-testid="configure-save-button" onClick={handleSubmit} disabled={!(dirty && isValid)}>
|
||||
{intl.formatMessage(messages.saveButton)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
|
||||
ConfigureModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onConfigureSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ConfigureModal;
|
||||
14
src/course-outline/configure-modal/ConfigureModal.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
.configure-modal {
|
||||
.configure-modal__header {
|
||||
padding-top: 1.5rem;
|
||||
position: static;
|
||||
}
|
||||
|
||||
.w-7rem {
|
||||
width: 7.2rem;
|
||||
}
|
||||
|
||||
.mw-1-25rem {
|
||||
min-width: 1.25rem;
|
||||
}
|
||||
}
|
||||
419
src/course-outline/configure-modal/ConfigureModal.test.jsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import ConfigureModal from './ConfigureModal';
|
||||
import messages from './messages';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
pathname: mockPathname,
|
||||
}),
|
||||
}));
|
||||
|
||||
const currentSectionMock = {
|
||||
displayName: 'Section1',
|
||||
category: 'chapter',
|
||||
start: '2025-08-10T10:00:00Z',
|
||||
visibilityState: true,
|
||||
format: 'Not Graded',
|
||||
childInfo: {
|
||||
displayName: 'Subsection',
|
||||
children: [
|
||||
{
|
||||
displayName: 'Subsection 1',
|
||||
id: 1,
|
||||
category: 'sequential',
|
||||
due: '',
|
||||
start: '2025-08-10T10:00:00Z',
|
||||
visibilityState: true,
|
||||
defaultTimeLimitMinutes: null,
|
||||
hideAfterDue: false,
|
||||
showCorrectness: false,
|
||||
format: 'Homework',
|
||||
courseGraders: ['Homework', 'Exam'],
|
||||
childInfo: {
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
{
|
||||
id: 11,
|
||||
displayName: 'Subsection_1 Unit 1',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Subsection 2',
|
||||
id: 2,
|
||||
category: 'sequential',
|
||||
due: '',
|
||||
start: '2025-08-10T10:00:00Z',
|
||||
visibilityState: true,
|
||||
defaultTimeLimitMinutes: null,
|
||||
hideAfterDue: false,
|
||||
showCorrectness: false,
|
||||
format: 'Homework',
|
||||
courseGraders: ['Homework', 'Exam'],
|
||||
childInfo: {
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
{
|
||||
id: 21,
|
||||
displayName: 'Subsection_2 Unit 1',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Subsection 3',
|
||||
id: 3,
|
||||
category: 'sequential',
|
||||
due: '',
|
||||
start: '2025-08-10T10:00:00Z',
|
||||
visibilityState: true,
|
||||
defaultTimeLimitMinutes: null,
|
||||
hideAfterDue: false,
|
||||
showCorrectness: false,
|
||||
format: 'Homework',
|
||||
courseGraders: ['Homework', 'Exam'],
|
||||
childInfo: {
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const onCloseMock = jest.fn();
|
||||
const onConfigureSubmitMock = jest.fn();
|
||||
|
||||
const renderComponent = () => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<ConfigureModal
|
||||
isOpen
|
||||
onClose={onCloseMock}
|
||||
onConfigureSubmit={onConfigureSubmitMock}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<ConfigureModal /> for Section', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
useSelector.mockReturnValue(currentSectionMock);
|
||||
});
|
||||
|
||||
it('renders ConfigureModal component correctly', () => {
|
||||
const { getByText, getByRole } = renderComponent();
|
||||
expect(getByText(`${currentSectionMock.displayName} Settings`)).toBeInTheDocument();
|
||||
expect(getByText(messages.basicTabTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.visibilityTabTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.releaseDate.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.releaseTimeUTC.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to the Visibility tab and renders correctly', () => {
|
||||
const { getByRole, getByText } = renderComponent();
|
||||
|
||||
const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage });
|
||||
fireEvent.click(visibilityTab);
|
||||
expect(getByText('Section Visibility')).toBeInTheDocument();
|
||||
expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the Save button and enables it if there is a change', () => {
|
||||
const { getByRole, getByPlaceholderText, getByTestId } = renderComponent();
|
||||
|
||||
const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
const input = getByPlaceholderText('MM/DD/YYYY');
|
||||
fireEvent.change(input, { target: { value: '12/15/2023' } });
|
||||
|
||||
const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage });
|
||||
fireEvent.click(visibilityTab);
|
||||
const checkbox = getByTestId('visibility-checkbox');
|
||||
fireEvent.click(checkbox);
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
const currentSubsectionMock = {
|
||||
displayName: 'Subsection 1',
|
||||
id: 1,
|
||||
category: 'sequential',
|
||||
due: '',
|
||||
start: '2025-08-10T10:00:00Z',
|
||||
visibilityState: true,
|
||||
defaultTimeLimitMinutes: null,
|
||||
hideAfterDue: false,
|
||||
showCorrectness: false,
|
||||
format: 'Homework',
|
||||
courseGraders: ['Homework', 'Exam'],
|
||||
childInfo: {
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
{
|
||||
id: 11,
|
||||
displayName: 'Subsection_1 Unit 1',
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
displayName: 'Subsection_1 Unit 2',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const renderSubsectionComponent = () => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<ConfigureModal
|
||||
isOpen
|
||||
onClose={onCloseMock}
|
||||
onConfigureSubmit={onConfigureSubmitMock}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<ConfigureModal /> for Subsection', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
useSelector.mockReturnValue(currentSubsectionMock);
|
||||
});
|
||||
|
||||
it('renders subsection ConfigureModal component correctly', () => {
|
||||
const { getByText, getByRole } = renderSubsectionComponent();
|
||||
expect(getByText(`${currentSubsectionMock.displayName} Settings`)).toBeInTheDocument();
|
||||
expect(getByText(messages.basicTabTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.visibilityTabTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.advancedTabTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.releaseDate.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.releaseTimeUTC.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.grading.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.gradeAs.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.dueDate.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.dueTimeUTC.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to the subsection Visibility tab and renders correctly', () => {
|
||||
const { getByRole, getByText } = renderSubsectionComponent();
|
||||
|
||||
const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage });
|
||||
fireEvent.click(visibilityTab);
|
||||
expect(getByText('Subsection Visibility')).toBeInTheDocument();
|
||||
expect(getByText(messages.showEntireSubsection.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.showEntireSubsectionDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.hideContentAfterDue.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.hideContentAfterDueDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.hideEntireSubsection.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.hideEntireSubsectionDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.assessmentResultsVisibility.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.alwaysShowAssessmentResults.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.alwaysShowAssessmentResultsDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.neverShowAssessmentResults.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.neverShowAssessmentResultsDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.showAssessmentResultsPastDue.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.showAssessmentResultsPastDueDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to the subsection Advanced tab and renders correctly', () => {
|
||||
const { getByRole, getByText } = renderSubsectionComponent();
|
||||
|
||||
const advancedTab = getByRole('tab', { name: messages.advancedTabTitle.defaultMessage });
|
||||
fireEvent.click(advancedTab);
|
||||
expect(getByText(messages.setSpecialExam.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.none.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.timed.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.timedDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the Save button and enables it if there is a change', () => {
|
||||
const { getByRole, getByTestId } = renderSubsectionComponent();
|
||||
|
||||
const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
const input = getByTestId('grader-type-select');
|
||||
fireEvent.change(input, { target: { value: 'Exam' } });
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
const currentUnitMock = {
|
||||
displayName: 'Unit 1',
|
||||
id: 1,
|
||||
category: 'vertical',
|
||||
due: '',
|
||||
start: '2025-08-10T10:00:00Z',
|
||||
visibilityState: true,
|
||||
defaultTimeLimitMinutes: null,
|
||||
hideAfterDue: false,
|
||||
showCorrectness: false,
|
||||
userPartitionInfo: {
|
||||
selectablePartitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 6,
|
||||
name: 'Honor',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 1508065533,
|
||||
name: 'Content Groups',
|
||||
scheme: 'cohort',
|
||||
groups: [
|
||||
{
|
||||
id: 1224170703,
|
||||
name: 'Content Group 1',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectedPartitionIndex: -1,
|
||||
selectedGroupsLabel: '',
|
||||
},
|
||||
};
|
||||
|
||||
const renderUnitComponent = () => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<ConfigureModal
|
||||
isOpen
|
||||
onClose={onCloseMock}
|
||||
onConfigureSubmit={onConfigureSubmitMock}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<ConfigureModal /> for Unit', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
useSelector.mockReturnValue(currentUnitMock);
|
||||
});
|
||||
|
||||
it('renders unit ConfigureModal component correctly', () => {
|
||||
const {
|
||||
getByText, queryByText, getByRole, getByTestId,
|
||||
} = renderUnitComponent();
|
||||
expect(getByText(`${currentUnitMock.displayName} Settings`)).toBeInTheDocument();
|
||||
expect(getByText(messages.unitVisibility.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.restrictAccessTo.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.unitSelectGroupType.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
expect(queryByText(messages.unitSelectGroup.defaultMessage)).not.toBeInTheDocument();
|
||||
const input = getByTestId('group-type-select');
|
||||
|
||||
[0, 1].forEach(groupeTypeIndex => {
|
||||
fireEvent.change(input, { target: { value: groupeTypeIndex } });
|
||||
|
||||
expect(getByText(messages.unitSelectGroup.defaultMessage)).toBeInTheDocument();
|
||||
currentUnitMock
|
||||
.userPartitionInfo
|
||||
.selectablePartitions[groupeTypeIndex].groups
|
||||
.forEach(g => expect(getByText(g.name)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the Save button and enables it if there is a change', () => {
|
||||
useSelector.mockReturnValue(
|
||||
{
|
||||
...currentUnitMock,
|
||||
userPartitionInfo: {
|
||||
...currentUnitMock.userPartitionInfo,
|
||||
selectedPartitionIndex: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
const { getByRole, getByTestId } = renderUnitComponent();
|
||||
|
||||
const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
const input = getByTestId('group-type-select');
|
||||
// unrestrict access
|
||||
fireEvent.change(input, { target: { value: -1 } });
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
|
||||
fireEvent.change(input, { target: { value: 0 } });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
const checkbox = getByTestId('unit-visibility-checkbox');
|
||||
fireEvent.click(checkbox);
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
117
src/course-outline/configure-modal/PrereqSettings.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Form } from '@edx/paragon';
|
||||
import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
import FormikControl from '../../generic/FormikControl';
|
||||
|
||||
const PrereqSettings = ({
|
||||
values,
|
||||
setFieldValue,
|
||||
prereqs,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
isPrereq,
|
||||
prereqUsageKey,
|
||||
prereqMinScore,
|
||||
prereqMinCompletion,
|
||||
} = values;
|
||||
|
||||
if (isPrereq === null || isPrereq === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSelectChange = (e) => {
|
||||
setFieldValue('prereqUsageKey', e.target.value);
|
||||
};
|
||||
|
||||
const prereqSelectionForm = () => (
|
||||
<>
|
||||
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.limitAccessTitle} /></h5>
|
||||
<hr />
|
||||
<Form>
|
||||
<Form.Text><FormattedMessage {...messages.limitAccessDescription} /></Form.Text>
|
||||
<Form.Group controlId="prereqForm.select">
|
||||
<Form.Label>
|
||||
{intl.formatMessage(messages.prerequisiteSelectLabel)}
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
defaultValue={prereqUsageKey}
|
||||
onChange={handleSelectChange}
|
||||
role="combobox"
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.noPrerequisiteOption)}
|
||||
</option>
|
||||
{prereqs.map((prereqOption) => (
|
||||
<option
|
||||
key={prereqOption.blockUsageKey}
|
||||
value={prereqOption.blockUsageKey}
|
||||
>
|
||||
{prereqOption.blockDisplayName}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
{prereqUsageKey && (
|
||||
<>
|
||||
<FormikControl
|
||||
name="prereqMinScore"
|
||||
value={prereqMinScore}
|
||||
label={<Form.Label>{intl.formatMessage(messages.minScoreLabel)}</Form.Label>}
|
||||
controlClassName="text-right"
|
||||
controlClasses="w-7rem"
|
||||
type="number"
|
||||
trailingElement="%"
|
||||
/>
|
||||
<FormikControl
|
||||
name="prereqMinCompletion"
|
||||
value={prereqMinCompletion}
|
||||
label={<Form.Label>{intl.formatMessage(messages.minCompletionLabel)}</Form.Label>}
|
||||
controlClassName="text-right"
|
||||
controlClasses="w-7rem"
|
||||
type="number"
|
||||
trailingElement="%"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
|
||||
const handleCheckboxChange = e => setFieldValue('isPrereq', e.target.checked);
|
||||
|
||||
return (
|
||||
<>
|
||||
{prereqs.length > 0 && prereqSelectionForm()}
|
||||
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.prereqTitle} /></h5>
|
||||
<hr />
|
||||
<Form.Checkbox checked={isPrereq} onChange={handleCheckboxChange}>
|
||||
<FormattedMessage {...messages.prereqCheckboxLabel} />
|
||||
</Form.Checkbox>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
PrereqSettings.defaultProps = {
|
||||
prereqs: [],
|
||||
};
|
||||
|
||||
PrereqSettings.propTypes = {
|
||||
values: PropTypes.shape({
|
||||
isPrereq: PropTypes.bool,
|
||||
prereqUsageKey: PropTypes.string,
|
||||
prereqMinScore: PropTypes.number,
|
||||
prereqMinCompletion: PropTypes.number,
|
||||
}).isRequired,
|
||||
prereqs: PropTypes.arrayOf(PropTypes.shape({
|
||||
blockUsageKey: PropTypes.string.isRequired,
|
||||
blockDisplayName: PropTypes.string.isRequired,
|
||||
})),
|
||||
setFieldValue: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(PrereqSettings);
|
||||
133
src/course-outline/configure-modal/UnitTab.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Form } from '@edx/paragon';
|
||||
import {
|
||||
FormattedMessage, injectIntl, useIntl,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Field } from 'formik';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const UnitTab = ({
|
||||
values,
|
||||
setFieldValue,
|
||||
showWarning,
|
||||
userPartitionInfo,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
isVisibleToStaffOnly,
|
||||
selectedPartitionIndex,
|
||||
} = values;
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFieldValue('isVisibleToStaffOnly', e.target.checked);
|
||||
};
|
||||
|
||||
const handleSelect = (e) => {
|
||||
setFieldValue('selectedPartitionIndex', parseInt(e.target.value, 10));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="mt-3"><FormattedMessage {...messages.unitVisibility} /></h3>
|
||||
<hr />
|
||||
<Form.Checkbox checked={isVisibleToStaffOnly} onChange={handleChange} data-testid="unit-visibility-checkbox">
|
||||
<FormattedMessage {...messages.hideFromLearners} />
|
||||
</Form.Checkbox>
|
||||
{showWarning && (
|
||||
<Alert className="mt-2" variant="warning">
|
||||
<FormattedMessage {...messages.unitVisibilityWarning} />
|
||||
</Alert>
|
||||
)}
|
||||
<hr />
|
||||
<Form.Group controlId="groupSelect">
|
||||
<Form.Label as="legend" className="font-weight-bold">
|
||||
<FormattedMessage {...messages.restrictAccessTo} />
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
name="groupSelect"
|
||||
value={selectedPartitionIndex}
|
||||
onChange={handleSelect}
|
||||
data-testid="group-type-select"
|
||||
>
|
||||
<option value="-1" key="-1">
|
||||
{userPartitionInfo.selectedPartitionIndex === -1
|
||||
? intl.formatMessage(messages.unitSelectGroupType)
|
||||
: intl.formatMessage(messages.unitAllLearnersAndStaff)}
|
||||
</option>
|
||||
{userPartitionInfo.selectablePartitions.map((partition, index) => (
|
||||
<option
|
||||
key={partition.id}
|
||||
value={index}
|
||||
>
|
||||
{partition.name}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
|
||||
{selectedPartitionIndex >= 0 && userPartitionInfo.selectablePartitions.length && (
|
||||
<Form.Group controlId="select-groups-checkboxes">
|
||||
<Form.Label><FormattedMessage {...messages.unitSelectGroup} /></Form.Label>
|
||||
<div
|
||||
role="group"
|
||||
className="d-flex flex-column"
|
||||
data-testid="group-checkboxes"
|
||||
aria-labelledby="select-groups-checkboxes"
|
||||
>
|
||||
{userPartitionInfo.selectablePartitions[selectedPartitionIndex].groups.map((group) => (
|
||||
<Form.Group
|
||||
key={group.id}
|
||||
className="pgn__form-checkbox"
|
||||
>
|
||||
<Field
|
||||
as={Form.Control}
|
||||
className="flex-grow-0 mr-1"
|
||||
controlClassName="pgn__form-checkbox-input mr-1"
|
||||
type="checkbox"
|
||||
value={`${group.id}`}
|
||||
name="selectedGroups"
|
||||
/>
|
||||
<Form.Label isInline>
|
||||
{group.name}
|
||||
</Form.Label>
|
||||
</Form.Group>
|
||||
))}
|
||||
</div>
|
||||
</Form.Group>
|
||||
)}
|
||||
</Form.Group>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
UnitTab.propTypes = {
|
||||
values: PropTypes.shape({
|
||||
isVisibleToStaffOnly: PropTypes.bool.isRequired,
|
||||
selectedPartitionIndex: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
]).isRequired,
|
||||
}).isRequired,
|
||||
setFieldValue: PropTypes.func.isRequired,
|
||||
showWarning: PropTypes.bool.isRequired,
|
||||
userPartitionInfo: PropTypes.shape({
|
||||
selectablePartitions: PropTypes.arrayOf(PropTypes.shape({
|
||||
groups: PropTypes.arrayOf(PropTypes.shape({
|
||||
deleted: PropTypes.bool.isRequired,
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
selected: PropTypes.bool.isRequired,
|
||||
}).isRequired).isRequired,
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
scheme: PropTypes.string.isRequired,
|
||||
}).isRequired).isRequired,
|
||||
selectedGroupsLabel: PropTypes.string,
|
||||
selectedPartitionIndex: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(UnitTab);
|
||||
135
src/course-outline/configure-modal/VisibilityTab.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Form } from '@edx/paragon';
|
||||
import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
|
||||
const VisibilityTab = ({
|
||||
values,
|
||||
setFieldValue,
|
||||
category,
|
||||
showWarning,
|
||||
isSubsection,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const visibilityTitle = COURSE_BLOCK_NAMES[category]?.name;
|
||||
|
||||
const {
|
||||
isVisibleToStaffOnly,
|
||||
hideAfterDue,
|
||||
showCorrectness,
|
||||
} = values;
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFieldValue('isVisibleToStaffOnly', e.target.checked);
|
||||
};
|
||||
|
||||
const getVisibilityValue = () => {
|
||||
if (isVisibleToStaffOnly) {
|
||||
return 'hide';
|
||||
}
|
||||
if (hideAfterDue) {
|
||||
return 'hideDue';
|
||||
}
|
||||
return 'show';
|
||||
};
|
||||
|
||||
const visibilityChanged = (e) => {
|
||||
const selected = e.target.value;
|
||||
if (selected === 'hide') {
|
||||
setFieldValue('isVisibleToStaffOnly', true);
|
||||
setFieldValue('hideAfterDue', false);
|
||||
} else if (selected === 'hideDue') {
|
||||
setFieldValue('isVisibleToStaffOnly', false);
|
||||
setFieldValue('hideAfterDue', true);
|
||||
} else {
|
||||
setFieldValue('isVisibleToStaffOnly', false);
|
||||
setFieldValue('hideAfterDue', false);
|
||||
}
|
||||
};
|
||||
|
||||
const correctnessChanged = (e) => {
|
||||
setFieldValue('showCorrectness', e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h5 className="mt-4 text-gray-700">
|
||||
{intl.formatMessage(messages.visibilitySectionTitle, { visibilityTitle })}
|
||||
</h5>
|
||||
<hr />
|
||||
{
|
||||
isSubsection ? (
|
||||
<>
|
||||
<Form.RadioSet
|
||||
name="subsectionVisibility"
|
||||
onChange={visibilityChanged}
|
||||
value={getVisibilityValue()}
|
||||
>
|
||||
<Form.Radio value="show">
|
||||
<FormattedMessage {...messages.showEntireSubsection} />
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.showEntireSubsectionDescription} /></Form.Text>
|
||||
<Form.Radio value="hideDue">
|
||||
<FormattedMessage {...messages.hideContentAfterDue} />
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.hideContentAfterDueDescription} /></Form.Text>
|
||||
<Form.Radio value="hide">
|
||||
<FormattedMessage {...messages.hideEntireSubsection} />
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.hideEntireSubsectionDescription} /></Form.Text>
|
||||
</Form.RadioSet>
|
||||
{showWarning && (
|
||||
<Alert className="mt-2" variant="warning">
|
||||
<FormattedMessage {...messages.subsectionVisibilityWarning} />
|
||||
</Alert>
|
||||
)}
|
||||
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.assessmentResultsVisibility} /></h5>
|
||||
<Form.RadioSet
|
||||
name="assessmentResultsVisibility"
|
||||
onChange={correctnessChanged}
|
||||
value={showCorrectness}
|
||||
>
|
||||
<Form.Radio value="always">
|
||||
<FormattedMessage {...messages.alwaysShowAssessmentResults} />
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.alwaysShowAssessmentResultsDescription} /></Form.Text>
|
||||
<Form.Radio value="never">
|
||||
<FormattedMessage {...messages.neverShowAssessmentResults} />
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.neverShowAssessmentResultsDescription} /></Form.Text>
|
||||
<Form.Radio value="past_due">
|
||||
<FormattedMessage {...messages.showAssessmentResultsPastDue} />
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.showAssessmentResultsPastDueDescription} /></Form.Text>
|
||||
</Form.RadioSet>
|
||||
</>
|
||||
) : (
|
||||
<Form.Checkbox checked={isVisibleToStaffOnly} onChange={handleChange} data-testid="visibility-checkbox">
|
||||
<FormattedMessage {...messages.hideFromLearners} />
|
||||
</Form.Checkbox>
|
||||
)
|
||||
}
|
||||
{showWarning && !isSubsection && (
|
||||
<Alert className="mt-2" variant="warning">
|
||||
<FormattedMessage {...messages.sectionVisibilityWarning} />
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
VisibilityTab.propTypes = {
|
||||
values: PropTypes.shape({
|
||||
isVisibleToStaffOnly: PropTypes.bool.isRequired,
|
||||
hideAfterDue: PropTypes.bool.isRequired,
|
||||
showCorrectness: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
setFieldValue: PropTypes.func.isRequired,
|
||||
category: PropTypes.string.isRequired,
|
||||
showWarning: PropTypes.bool.isRequired,
|
||||
isSubsection: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(VisibilityTab);
|
||||
270
src/course-outline/configure-modal/messages.js
Normal file
@@ -0,0 +1,270 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'course-authoring.course-outline.configure-modal.title',
|
||||
defaultMessage: '{title} Settings',
|
||||
},
|
||||
basicTabTitle: {
|
||||
id: 'course-authoring.course-outline.configure-modal.basic-tab.title',
|
||||
defaultMessage: 'Basic',
|
||||
},
|
||||
notGradedTypeOption: {
|
||||
id: 'course-authoring.course-outline.configure-modal.basic-tab.notGradedTypeOption',
|
||||
defaultMessage: 'Not Graded',
|
||||
},
|
||||
releaseDateAndTime: {
|
||||
id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date-and-time',
|
||||
defaultMessage: 'Release Date and Time',
|
||||
},
|
||||
releaseDate: {
|
||||
id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date',
|
||||
defaultMessage: 'Release Date:',
|
||||
},
|
||||
releaseTimeUTC: {
|
||||
id: 'course-authoring.course-outline.configure-modal.basic-tab.release-time-UTC',
|
||||
defaultMessage: 'Release Time in UTC:',
|
||||
},
|
||||
visibilityTabTitle: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.title',
|
||||
defaultMessage: 'Visibility',
|
||||
},
|
||||
visibilitySectionTitle: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.section-visibility',
|
||||
defaultMessage: '{visibilityTitle} Visibility',
|
||||
},
|
||||
unitVisibility: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.unit-visibility',
|
||||
defaultMessage: 'Unit Visibility',
|
||||
},
|
||||
hideFromLearners: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility.hide-from-learners',
|
||||
defaultMessage: 'Hide from learners',
|
||||
},
|
||||
restrictAccessTo: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility.restrict-access-to',
|
||||
defaultMessage: 'Restrict access to',
|
||||
},
|
||||
sectionVisibilityWarning: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.section-visibility-warning',
|
||||
defaultMessage: 'If you make this section visible to learners, learners will be able to see its content after the release date has passed and you have published the section. Only units that are explicitly hidden from learners will remain hidden after you clear this option for the section.',
|
||||
},
|
||||
unitVisibilityWarning: {
|
||||
id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-visibility-warning',
|
||||
defaultMessage: 'If the unit was previously published and released to learners, any changes you made to the unit when it was hidden will now be visible to learners.',
|
||||
},
|
||||
subsectionVisibilityWarning: {
|
||||
id: 'course-authoring.course-outline.configure-modal.unit-tab.subsection-visibility-warning',
|
||||
defaultMessage: 'If you select an option other than "Hide entire subsection", published units in this subsection will become available to learners unless they are explicitly hidden.',
|
||||
},
|
||||
unitSelectGroup: {
|
||||
id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-select-group',
|
||||
defaultMessage: 'Select one or more groups:',
|
||||
},
|
||||
unitSelectGroupType: {
|
||||
id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-select-group-type',
|
||||
defaultMessage: 'Select a group type',
|
||||
},
|
||||
unitAllLearnersAndStaff: {
|
||||
id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-all-learners-staff',
|
||||
defaultMessage: 'All Learners and Staff',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'course-authoring.course-outline.configure-modal.button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
saveButton: {
|
||||
id: 'course-authoring.course-outline.configure-modal.button.label',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
grading: {
|
||||
id: 'course-authoring.course-outline.configure-modal.basic-tab.grading',
|
||||
defaultMessage: 'Grading',
|
||||
},
|
||||
gradeAs: {
|
||||
id: 'course-authoring.course-outline.configure-modal.basic-tab.grade-as',
|
||||
defaultMessage: 'Grade as:',
|
||||
},
|
||||
dueDate: {
|
||||
id: 'course-authoring.course-outline.configure-modal.basic-tab.due-date',
|
||||
defaultMessage: 'Due Date:',
|
||||
},
|
||||
dueTimeUTC: {
|
||||
id: 'course-authoring.course-outline.configure-modal.basic-tab.due-time-UTC',
|
||||
defaultMessage: 'Due Time in UTC:',
|
||||
},
|
||||
subsectionVisibility: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.subsection-visibility',
|
||||
defaultMessage: 'Subsection Visibility',
|
||||
},
|
||||
showEntireSubsection: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-entire-subsection',
|
||||
defaultMessage: 'Show entire subsection',
|
||||
},
|
||||
showEntireSubsectionDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-entire-subsection-description',
|
||||
defaultMessage: 'Learners see the published subsection and can access its content',
|
||||
},
|
||||
hideContentAfterDue: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-due',
|
||||
defaultMessage: 'Hide content after due date',
|
||||
},
|
||||
hideContentAfterDueDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-due-description',
|
||||
defaultMessage: 'After the subsection\'s due date has passed, learners can no longer access its content. The subsection is not included in grade calculations.',
|
||||
},
|
||||
hideEntireSubsection: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-entire-subsection',
|
||||
defaultMessage: 'Hide entire subsection',
|
||||
},
|
||||
hideEntireSubsectionDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-entire-subsection-description',
|
||||
defaultMessage: 'Learners do not see the subsection in the course outline. The subsection is not included in grade calculations.',
|
||||
},
|
||||
assessmentResultsVisibility: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.assessment-results-visibility',
|
||||
defaultMessage: 'Assessment Results Visibility',
|
||||
},
|
||||
alwaysShowAssessmentResults: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.always-show-assessment-results',
|
||||
defaultMessage: 'Always show assessment results',
|
||||
},
|
||||
alwaysShowAssessmentResultsDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.always-show-assessment-results-description',
|
||||
defaultMessage: 'When learners submit an answer to an assessment, they immediately see whether the answer is correct or incorrect, and the score received.',
|
||||
},
|
||||
neverShowAssessmentResults: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.never-show-assessment-results',
|
||||
defaultMessage: 'Never show assessment results',
|
||||
},
|
||||
neverShowAssessmentResultsDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.never-show-assessment-results-description',
|
||||
defaultMessage: 'Learners never see whether their answers to assessments are correct or incorrect, nor the score received.',
|
||||
},
|
||||
showAssessmentResultsPastDue: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-assessment-results-past-due',
|
||||
defaultMessage: 'Show assessment results when subsection is past due',
|
||||
},
|
||||
showAssessmentResultsPastDueDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-assessment-results-past-due-description',
|
||||
defaultMessage: 'Learners do not see whether their answer to assessments were correct or incorrect, nor the score received, until after the due date for the subsection has passed. If the subsection does not have a due date, learners always see their scores when they submit answers to assessments.',
|
||||
},
|
||||
setSpecialExam: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.set-special-exam',
|
||||
defaultMessage: 'Set as a Special Exam',
|
||||
},
|
||||
none: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.none',
|
||||
defaultMessage: 'None',
|
||||
},
|
||||
timed: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed',
|
||||
defaultMessage: 'Timed',
|
||||
},
|
||||
timedDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description',
|
||||
defaultMessage: 'Use a timed exam to limit the time learners can spend on problems in this subsection. Learners must submit answers before the time expires. You can allow additional time for individual learners through the instructor Dashboard.',
|
||||
},
|
||||
proctoredExam: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.proctoredExam',
|
||||
defaultMessage: 'Proctored',
|
||||
},
|
||||
proctoredExamDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description',
|
||||
defaultMessage: 'Proctored exams are timed and they record video of each learner taking the exam. The videos are then reviewed to ensure that learners follow all examination rules. Please note that setting this exam as proctored will change the visibility settings to "Hide content after due date."',
|
||||
},
|
||||
onboardingExam: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.onboardingExam',
|
||||
defaultMessage: 'Onboarding',
|
||||
},
|
||||
onboardingExamDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description',
|
||||
defaultMessage: 'Use Onboarding to introduce learners to proctoring, verify their identity, and create an onboarding profile. Learners must complete the onboarding profile step prior to taking a proctored exam. Profile reviews take 2+ business days.',
|
||||
},
|
||||
practiceExam: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.practiceExam',
|
||||
defaultMessage: 'Practice proctored',
|
||||
},
|
||||
practiceExamDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description',
|
||||
defaultMessage: 'Use a practice proctored exam to introduce learners to the proctoring tools and processes. Results of a practice exam do not affect a learner\'s grade.',
|
||||
},
|
||||
advancedTabTitle: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.title',
|
||||
defaultMessage: 'Advanced',
|
||||
},
|
||||
timeAllotted: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-allotted',
|
||||
defaultMessage: 'Time Allotted (HH:MM):',
|
||||
},
|
||||
timeLimitDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-limit-description',
|
||||
defaultMessage: 'Select a time allotment for the exam. If it is over 24 hours, type in the amount of time. You can grant individual learners extra time to complete the exam through the Instructor Dashboard.',
|
||||
},
|
||||
prereqTitle: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.prereqTitle',
|
||||
defaultMessage: 'Use as a Prerequisite',
|
||||
},
|
||||
prereqCheckboxLabel: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.prereqCheckboxLabel',
|
||||
defaultMessage: 'Make this subsection available as a prerequisite to other content',
|
||||
},
|
||||
limitAccessTitle: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.limitAccessTitle',
|
||||
defaultMessage: 'Limit access',
|
||||
},
|
||||
limitAccessDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.limitAccessDescription',
|
||||
defaultMessage: 'Select a prerequisite subsection and enter a minimum score percentage and minimum completion percentage to limit access to this subsection. Allowed values are 0-100',
|
||||
},
|
||||
noPrerequisiteOption: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.noPrerequisiteOption',
|
||||
defaultMessage: 'No prerequisite',
|
||||
},
|
||||
prerequisiteSelectLabel: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.prerequisiteSelectLabel',
|
||||
defaultMessage: 'Prerequisite:',
|
||||
},
|
||||
minScoreLabel: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.minScoreLabel',
|
||||
defaultMessage: 'Minimum score:',
|
||||
},
|
||||
minCompletionLabel: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.minCompletionLabel',
|
||||
defaultMessage: 'Minimum completion:',
|
||||
},
|
||||
minScoreError: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.minScoreError',
|
||||
defaultMessage: 'The minimum score percentage must be a whole number between 0 and 100.',
|
||||
},
|
||||
minCompletionError: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.minCompletionError',
|
||||
defaultMessage: 'The minimum completion percentage must be a whole number between 0 and 100.',
|
||||
},
|
||||
proctoredExamLockedAndisNotProctoredExamAlert: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.proctoredExamLockedAndisNotProctoredExamAlert',
|
||||
defaultMessage: 'This subsection was released to learners as a proctored exam, but was reverted back to a basic or timed exam. You may not configure it as a proctored exam now. Contact edX Support for assistance.',
|
||||
},
|
||||
proctoredExamLockedAndisProctoredExamAlert: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.proctoredExamLockedAndisProctoredExamAlert',
|
||||
defaultMessage: 'This proctored exam has been released to learners. You may not convert it to another type of special exam. You may revert this subsection back to being a basic exam by selecting \'None\', or a timed exam, but you will NOT be able to configure it as a proctored exam in the future.',
|
||||
},
|
||||
reviewRulesLabel: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesLabel',
|
||||
defaultMessage: 'Review rules',
|
||||
},
|
||||
reviewRulesDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesDescription',
|
||||
defaultMessage: 'Specify any rules or rule exceptions that the proctoring review team should enforce when reviewing the videos. For example, you could specify that calculators are allowed. These specified rules are visible to learners before the learners start the exam.',
|
||||
},
|
||||
reviewRulesDescriptionWithLink: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesDescriptionWithLink',
|
||||
defaultMessage: 'Specify any rules or rule exceptions that the proctoring review team should enforce when reviewing the videos. For example, you could specify that calculators are allowed. These specified rules are visible to learners before the learners start the exam, along with the {hyperlink}.',
|
||||
},
|
||||
reviewRulesDescriptionLinkText: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesDescriptionLinkText',
|
||||
defaultMessage: 'general proctored exam rules',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
82
src/course-outline/constants.js
Normal file
@@ -0,0 +1,82 @@
|
||||
export const ITEM_BADGE_STATUS = /** @type {const} */ ({
|
||||
live: 'live',
|
||||
gated: 'gated',
|
||||
publishedNotLive: 'published_not_live',
|
||||
unpublishedChanges: 'unpublished_changes',
|
||||
staffOnly: 'staff_only',
|
||||
draft: 'draft',
|
||||
});
|
||||
|
||||
export const HIGHLIGHTS_FIELD_MAX_LENGTH = 250;
|
||||
|
||||
export const CHECKLIST_FILTERS = /** @type {const} */ ({
|
||||
ALL: 'ALL',
|
||||
SELF_PACED: 'SELF_PACED',
|
||||
INSTRUCTOR_PACED: 'INSTRUCTOR_PACED',
|
||||
});
|
||||
|
||||
export const COURSE_BLOCK_NAMES = /** @type {const} */ ({
|
||||
chapter: { id: 'chapter', name: 'Section' },
|
||||
sequential: { id: 'sequential', name: 'Subsection' },
|
||||
vertical: { id: 'vertical', name: 'Unit' },
|
||||
});
|
||||
|
||||
export const LAUNCH_CHECKLIST = /** @type {const} */ ({
|
||||
data: [
|
||||
{
|
||||
id: 'welcomeMessage',
|
||||
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
|
||||
},
|
||||
{
|
||||
id: 'gradingPolicy',
|
||||
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
|
||||
},
|
||||
{
|
||||
id: 'certificate',
|
||||
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
|
||||
},
|
||||
{
|
||||
id: 'courseDates',
|
||||
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
|
||||
},
|
||||
{
|
||||
id: 'assignmentDeadlines',
|
||||
pacingTypeFilter: CHECKLIST_FILTERS.INSTRUCTOR_PACED,
|
||||
},
|
||||
{
|
||||
id: 'proctoringEmail',
|
||||
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const BEST_PRACTICES_CHECKLIST = /** @type {const} */ ({
|
||||
data: [
|
||||
{
|
||||
id: 'videoDuration',
|
||||
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
|
||||
},
|
||||
{
|
||||
id: 'mobileFriendlyVideo',
|
||||
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
|
||||
},
|
||||
{
|
||||
id: 'diverseSequences',
|
||||
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
|
||||
},
|
||||
{
|
||||
id: 'weeklyHighlights',
|
||||
pacingTypeFilter: CHECKLIST_FILTERS.SELF_PACED,
|
||||
},
|
||||
{
|
||||
id: 'unitDepth',
|
||||
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const VIDEO_SHARING_OPTIONS = /** @type {const} */ ({
|
||||
perVideo: 'per-video',
|
||||
allOn: 'all-on',
|
||||
allOff: 'all-off',
|
||||
});
|
||||
474
src/course-outline/data/api.js
Normal file
@@ -0,0 +1,474 @@
|
||||
// @ts-check
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
export const getCourseOutlineIndexApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`;
|
||||
|
||||
export const getCourseBestPracticesApiUrl = ({
|
||||
courseId,
|
||||
excludeGraded,
|
||||
all,
|
||||
}) => `${getApiBaseUrl()}/api/courses/v1/quality/${courseId}/?exclude_graded=${excludeGraded}&all=${all}`;
|
||||
|
||||
export const getCourseLaunchApiUrl = ({
|
||||
courseId,
|
||||
gradedOnly,
|
||||
validateOras,
|
||||
all,
|
||||
}) => `${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`;
|
||||
|
||||
export const getCourseBlockApiUrl = (courseId) => {
|
||||
const formattedCourseId = courseId.split('course-v1:')[1];
|
||||
return `${getApiBaseUrl()}/xblock/block-v1:${formattedCourseId}+type@course+block@course`;
|
||||
};
|
||||
|
||||
export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${reindexLink}`;
|
||||
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
|
||||
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
|
||||
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
|
||||
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;
|
||||
|
||||
/**
|
||||
* @typedef {Object} courseOutline
|
||||
* @property {string} courseReleaseDate
|
||||
* @property {Object} courseStructure
|
||||
* @property {Object} deprecatedBlocksInfo
|
||||
* @property {string} discussionsIncontextFeedbackUrl
|
||||
* @property {string} discussionsIncontextLearnmoreUrl
|
||||
* @property {Object} initialState
|
||||
* @property {Object} initialUserClipboard
|
||||
* @property {string} languageCode
|
||||
* @property {string} lmsLink
|
||||
* @property {string} mfeProctoredExamSettingsUrl
|
||||
* @property {string} notificationDismissUrl
|
||||
* @property {string[]} proctoringErrors
|
||||
* @property {string} reindexLink
|
||||
* @property {null} rerunNotificationId
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get course outline index.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<courseOutline>}
|
||||
*/
|
||||
export async function getCourseOutlineIndex(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseOutlineIndexApiUrl(courseId));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get course best practices.
|
||||
* @param {{courseId: string, excludeGraded: boolean, all: boolean}} options
|
||||
* @returns {Promise<{isSelfPaced: boolean, sections: any, subsection: any, units: any, videos: any }>}
|
||||
*/
|
||||
export async function getCourseBestPractices({
|
||||
courseId,
|
||||
excludeGraded,
|
||||
all,
|
||||
}) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseBestPracticesApiUrl({ courseId, excludeGraded, all }));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/** @typedef {object} courseLaunchData
|
||||
* @property {boolean} isSelfPaced
|
||||
* @property {object} dates
|
||||
* @property {object} assignments
|
||||
* @property {object} grades
|
||||
* @property {number} grades.sum_of_weights
|
||||
* @property {object} certificates
|
||||
* @property {object} updates
|
||||
* @property {object} proctoring
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get course launch.
|
||||
* @param {{courseId: string, gradedOnly: boolean, validateOras: boolean, all: boolean}} options
|
||||
* @returns {Promise<courseLaunchData>}
|
||||
*/
|
||||
export async function getCourseLaunch({
|
||||
courseId,
|
||||
gradedOnly,
|
||||
validateOras,
|
||||
all,
|
||||
}) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseLaunchApiUrl({
|
||||
courseId, gradedOnly, validateOras, all,
|
||||
}));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable course highlights emails
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function enableCourseHighlightsEmails(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseBlockApiUrl(courseId), {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
highlights_enabled_for_messaging: true,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart reindex course
|
||||
* @param {string} reindexLink
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function restartIndexingOnCourse(reindexLink) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseReindexApiUrl(reindexLink));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} section
|
||||
* @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} releaseDate
|
||||
* @property {string} visibilityState
|
||||
* @property {boolean} hasExplicitStaffLock
|
||||
* @property {string} start
|
||||
* @property {boolean} graded
|
||||
* @property {string} dueDate
|
||||
* @property {null} due
|
||||
* @property {null} relativeWeeksDue
|
||||
* @property {null} format
|
||||
* @property {string[]} courseGraders
|
||||
* @property {boolean} hasChanges
|
||||
* @property {object} actions
|
||||
* @property {null} explanatoryMessage
|
||||
* @property {object[]} userPartitions
|
||||
* @property {string} showCorrectness
|
||||
* @property {string[]} highlights
|
||||
* @property {boolean} highlightsEnabled
|
||||
* @property {boolean} highlightsPreviewOnly
|
||||
* @property {string} highlightsDocUrl
|
||||
* @property {object} childInfo
|
||||
* @property {boolean} ancestorHasStaffLock
|
||||
* @property {boolean} staffOnlyMessage
|
||||
* @property {boolean} hasPartitionGroupComponents
|
||||
* @property {object} userPartitionInfo
|
||||
* @property {boolean} enableCopyPasteUnits
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get course section
|
||||
* @param {string} itemId
|
||||
* @returns {Promise<section>}
|
||||
*/
|
||||
export async function getCourseItem(itemId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getXBlockApiUrl(itemId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update course section highlights
|
||||
* @param {string} sectionId
|
||||
* @param {Array<string>} highlights
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function updateCourseSectionHighlights(sectionId, highlights) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(sectionId), {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
highlights,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish course section
|
||||
* @param {string} sectionId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function publishCourseSection(sectionId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(sectionId), {
|
||||
publish: 'make_public',
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure course section
|
||||
* @param {string} sectionId
|
||||
* @param {boolean} isVisibleToStaffOnly
|
||||
* @param {string} startDatetime
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(sectionId), {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
// The backend expects metadata.visible_to_staff_only to either true or null
|
||||
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
|
||||
start: startDatetime,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure course section
|
||||
* @param {string} itemId
|
||||
* @param {string} isVisibleToStaffOnly
|
||||
* @param {string} releaseDate
|
||||
* @param {string} graderType
|
||||
* @param {string} dueDate
|
||||
* @param {boolean} isProctoredExam,
|
||||
* @param {boolean} isOnboardingExam,
|
||||
* @param {boolean} isPracticeExam,
|
||||
* @param {string} examReviewRules,
|
||||
* @param {boolean} isTimeLimited
|
||||
* @param {number} defaultTimeLimitMin
|
||||
* @param {string} hideAfterDue
|
||||
* @param {string} showCorrectness
|
||||
* @param {boolean} isPrereq,
|
||||
* @param {string} prereqUsageKey,
|
||||
* @param {number} prereqMinScore,
|
||||
* @param {number} prereqMinCompletion,
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseSubsection(
|
||||
itemId,
|
||||
isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
graderType,
|
||||
dueDate,
|
||||
isTimeLimited,
|
||||
isProctoredExam,
|
||||
isOnboardingExam,
|
||||
isPracticeExam,
|
||||
examReviewRules,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDue,
|
||||
showCorrectness,
|
||||
isPrereq,
|
||||
prereqUsageKey,
|
||||
prereqMinScore,
|
||||
prereqMinCompletion,
|
||||
) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(itemId), {
|
||||
publish: 'republish',
|
||||
graderType,
|
||||
isPrereq,
|
||||
prereqUsageKey,
|
||||
prereqMinScore,
|
||||
prereqMinCompletion,
|
||||
metadata: {
|
||||
// The backend expects metadata.visible_to_staff_only to either true or null
|
||||
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
|
||||
due: dueDate,
|
||||
hide_after_due: hideAfterDue,
|
||||
show_correctness: showCorrectness,
|
||||
is_practice_exam: isPracticeExam,
|
||||
is_time_limited: isTimeLimited,
|
||||
is_proctored_enabled: isProctoredExam || isPracticeExam || isOnboardingExam,
|
||||
exam_review_rules: examReviewRules,
|
||||
default_time_limit_minutes: defaultTimeLimitMin,
|
||||
is_onboarding_exam: isOnboardingExam,
|
||||
start: releaseDate,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure course unit
|
||||
* @param {string} unitId
|
||||
* @param {boolean} isVisibleToStaffOnly
|
||||
* @param {object} groupAccess
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAccess) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(unitId), {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
// The backend expects metadata.visible_to_staff_only to either true or null
|
||||
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
|
||||
group_access: groupAccess,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit course section
|
||||
* @param {string} itemId
|
||||
* @param {string} displayName
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function editItemDisplayName(itemId, displayName) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(itemId), {
|
||||
metadata: {
|
||||
display_name: displayName,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete course section
|
||||
* @param {string} itemId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function deleteCourseItem(itemId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.delete(getCourseItemApiUrl(itemId));
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate course section
|
||||
* @param {string} itemId
|
||||
* @param {string} parentId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function duplicateCourseItem(itemId, parentId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(), {
|
||||
duplicate_source_locator: itemId,
|
||||
parent_locator: parentId,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new course item like section, subsection or unit.
|
||||
* @param {string} parentLocator
|
||||
* @param {string} category
|
||||
* @param {string} displayName
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function addNewCourseItem(parentLocator, category, displayName) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(), {
|
||||
parent_locator: parentLocator,
|
||||
category,
|
||||
display_name: displayName,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set order for the list of the sections
|
||||
* @param {string} courseId
|
||||
* @param {Array<string>} children list of sections id's
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function setSectionOrderList(courseId, children) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.put(getCourseBlockApiUrl(courseId), {
|
||||
children,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set order for the list of the subsections
|
||||
* @param {string} itemId Subsection or unit ID
|
||||
* @param {Array<string>} children list of sections id's
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function setCourseItemOrderList(itemId, children) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.put(getCourseItemApiUrl(itemId), {
|
||||
children,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set video sharing setting
|
||||
* @param {string} courseId
|
||||
* @param {string} videoSharingOption
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function setVideoSharingOption(courseId, videoSharingOption) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseBlockApiUrl(courseId), {
|
||||
metadata: {
|
||||
video_sharing_options: videoSharingOption,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy block to clipboard
|
||||
* @param {string} usageKey
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function copyBlockToClipboard(usageKey) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getClipboardUrl(), {
|
||||
usage_key: usageKey,
|
||||
});
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste block to clipboard
|
||||
* @param {string} parentLocator
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function pasteBlock(parentLocator) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(), {
|
||||
parent_locator: parentLocator,
|
||||
staged_content: 'clipboard',
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss notification
|
||||
* @param {string} url
|
||||
* @returns void
|
||||
*/
|
||||
export async function dismissNotification(url) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(url);
|
||||
}
|
||||
12
src/course-outline/data/selectors.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export const getOutlineIndexData = (state) => state.courseOutline.outlineIndexData;
|
||||
export const getLoadingStatus = (state) => state.courseOutline.loadingStatus;
|
||||
export const getStatusBarData = (state) => state.courseOutline.statusBarData;
|
||||
export const getSavingStatus = (state) => state.courseOutline.savingStatus;
|
||||
export const getSectionsList = (state) => state.courseOutline.sectionsList;
|
||||
export const getCurrentItem = (state) => state.courseOutline.currentItem;
|
||||
export const getCurrentSection = (state) => state.courseOutline.currentSection;
|
||||
export const getCurrentSubsection = (state) => state.courseOutline.currentSubsection;
|
||||
export const getCourseActions = (state) => state.courseOutline.actions;
|
||||
export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive;
|
||||
export const getInitialUserClipboard = (state) => state.courseOutline.initialUserClipboard;
|
||||
export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams;
|
||||
225
src/course-outline/data/slice.js
Normal file
@@ -0,0 +1,225 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { VIDEO_SHARING_OPTIONS } from '../constants';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'courseOutline',
|
||||
initialState: {
|
||||
loadingStatus: {
|
||||
outlineIndexLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
reIndexLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
fetchSectionLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
},
|
||||
outlineIndexData: {},
|
||||
savingStatus: '',
|
||||
statusBarData: {
|
||||
courseReleaseDate: '',
|
||||
highlightsEnabledForMessaging: false,
|
||||
isSelfPaced: false,
|
||||
checklist: {
|
||||
totalCourseLaunchChecks: 0,
|
||||
completedCourseLaunchChecks: 0,
|
||||
totalCourseBestPracticesChecks: 0,
|
||||
completedCourseBestPracticesChecks: 0,
|
||||
},
|
||||
videoSharingEnabled: false,
|
||||
videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo,
|
||||
},
|
||||
sectionsList: [],
|
||||
isCustomRelativeDatesActive: false,
|
||||
currentSection: {},
|
||||
currentSubsection: {},
|
||||
currentItem: {},
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
initialUserClipboard: {
|
||||
content: {},
|
||||
sourceUsageKey: null,
|
||||
sourceContexttitle: null,
|
||||
sourceEditUrl: null,
|
||||
},
|
||||
enableProctoredExams: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchOutlineIndexSuccess: (state, { payload }) => {
|
||||
state.outlineIndexData = payload;
|
||||
state.sectionsList = payload.courseStructure?.childInfo?.children || [];
|
||||
state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive;
|
||||
state.initialUserClipboard = payload.initialUserClipboard;
|
||||
state.enableProctoredExams = payload.courseStructure?.enableProctoredExams;
|
||||
},
|
||||
updateOutlineIndexLoadingStatus: (state, { payload }) => {
|
||||
state.loadingStatus = {
|
||||
...state.loadingStatus,
|
||||
outlineIndexLoadingStatus: payload.status,
|
||||
};
|
||||
},
|
||||
updateReindexLoadingStatus: (state, { payload }) => {
|
||||
state.loadingStatus = {
|
||||
...state.loadingStatus,
|
||||
reIndexLoadingStatus: payload.status,
|
||||
};
|
||||
},
|
||||
updateFetchSectionLoadingStatus: (state, { payload }) => {
|
||||
state.loadingStatus = {
|
||||
...state.loadingStatus,
|
||||
fetchSectionLoadingStatus: payload.status,
|
||||
};
|
||||
},
|
||||
updateStatusBar: (state, { payload }) => {
|
||||
state.statusBarData = {
|
||||
...state.statusBarData,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
updateClipboardContent: (state, { payload }) => {
|
||||
state.initialUserClipboard = payload;
|
||||
},
|
||||
updateCourseActions: (state, { payload }) => {
|
||||
state.actions = {
|
||||
...state.actions,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
fetchStatusBarChecklistSuccess: (state, { payload }) => {
|
||||
state.statusBarData.checklist = {
|
||||
...state.statusBarData.checklist,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
fetchStatusBarSelPacedSuccess: (state, { payload }) => {
|
||||
state.statusBarData.isSelfPaced = payload.isSelfPaced;
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
updateSectionList: (state, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => (section.id === payload.id ? payload : section));
|
||||
},
|
||||
setCurrentItem: (state, { payload }) => {
|
||||
state.currentItem = payload;
|
||||
},
|
||||
reorderSectionList: (state, { payload }) => {
|
||||
const sectionsList = [...state.sectionsList];
|
||||
sectionsList.sort((a, b) => payload.indexOf(a.id) - payload.indexOf(b.id));
|
||||
|
||||
state.sectionsList = [...sectionsList];
|
||||
},
|
||||
reorderSubsectionList: (state, { payload }) => {
|
||||
const { sectionId, subsectionListIds } = payload;
|
||||
const sections = [...state.sectionsList];
|
||||
const i = sections.findIndex(section => section.id === sectionId);
|
||||
sections[i].childInfo.children.sort((a, b) => subsectionListIds.indexOf(a.id) - subsectionListIds.indexOf(b.id));
|
||||
state.sectionsList = [...sections];
|
||||
},
|
||||
reorderUnitList: (state, { payload }) => {
|
||||
const { sectionId, subsectionId, unitListIds } = payload;
|
||||
const sections = [...state.sectionsList];
|
||||
const i = sections.findIndex(section => section.id === sectionId);
|
||||
const j = sections[i].childInfo.children.findIndex(subsection => subsection.id === subsectionId);
|
||||
const subsection = sections[i].childInfo.children[j];
|
||||
subsection.childInfo.children.sort((a, b) => unitListIds.indexOf(a.id) - unitListIds.indexOf(b.id));
|
||||
state.sectionsList = [...sections];
|
||||
},
|
||||
setCurrentSection: (state, { payload }) => {
|
||||
state.currentSection = payload;
|
||||
},
|
||||
setCurrentSubsection: (state, { payload }) => {
|
||||
state.currentSubsection = payload;
|
||||
},
|
||||
addSection: (state, { payload }) => {
|
||||
state.sectionsList = [
|
||||
...state.sectionsList,
|
||||
payload,
|
||||
];
|
||||
},
|
||||
addSubsection: (state, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id === payload.parentLocator) {
|
||||
section.childInfo.children = [
|
||||
...section.childInfo.children,
|
||||
payload.data,
|
||||
];
|
||||
}
|
||||
return section;
|
||||
});
|
||||
},
|
||||
deleteSection: (state, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.filter(
|
||||
({ id }) => id !== payload.itemId,
|
||||
);
|
||||
},
|
||||
deleteSubsection: (state, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id !== payload.sectionId) {
|
||||
return section;
|
||||
}
|
||||
section.childInfo.children = section.childInfo.children.filter(
|
||||
({ id }) => id !== payload.itemId,
|
||||
);
|
||||
return section;
|
||||
});
|
||||
},
|
||||
deleteUnit: (state, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id !== payload.sectionId) {
|
||||
return section;
|
||||
}
|
||||
section.childInfo.children = section.childInfo.children.map((subsection) => {
|
||||
if (subsection.id !== payload.subsectionId) {
|
||||
return subsection;
|
||||
}
|
||||
subsection.childInfo.children = subsection.childInfo.children.filter(
|
||||
({ id }) => id !== payload.itemId,
|
||||
);
|
||||
return subsection;
|
||||
});
|
||||
return section;
|
||||
});
|
||||
},
|
||||
duplicateSection: (state, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.reduce((result, currentValue) => {
|
||||
if (currentValue.id === payload.id) {
|
||||
return [...result, currentValue, payload.duplicatedItem];
|
||||
}
|
||||
return [...result, currentValue];
|
||||
}, []);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
addSection,
|
||||
addSubsection,
|
||||
fetchOutlineIndexSuccess,
|
||||
updateOutlineIndexLoadingStatus,
|
||||
updateReindexLoadingStatus,
|
||||
updateStatusBar,
|
||||
updateCourseActions,
|
||||
fetchStatusBarChecklistSuccess,
|
||||
fetchStatusBarSelPacedSuccess,
|
||||
updateFetchSectionLoadingStatus,
|
||||
updateSavingStatus,
|
||||
updateSectionList,
|
||||
setCurrentItem,
|
||||
setCurrentSection,
|
||||
setCurrentSubsection,
|
||||
deleteSection,
|
||||
deleteSubsection,
|
||||
deleteUnit,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
reorderSubsectionList,
|
||||
reorderUnitList,
|
||||
updateClipboardContent,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
640
src/course-outline/data/thunk.js
Normal file
@@ -0,0 +1,640 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import {
|
||||
hideProcessingNotification,
|
||||
showProcessingNotification,
|
||||
} from '../../generic/processing-notification/data/slice';
|
||||
import {
|
||||
getCourseBestPracticesChecklist,
|
||||
getCourseLaunchChecklist,
|
||||
} from '../utils/getChecklistForStatusBar';
|
||||
import {
|
||||
addNewCourseItem,
|
||||
deleteCourseItem,
|
||||
duplicateCourseItem,
|
||||
editItemDisplayName,
|
||||
enableCourseHighlightsEmails,
|
||||
getCourseBestPractices,
|
||||
getCourseLaunch,
|
||||
getCourseOutlineIndex,
|
||||
getCourseItem,
|
||||
publishCourseSection,
|
||||
configureCourseSection,
|
||||
configureCourseSubsection,
|
||||
configureCourseUnit,
|
||||
restartIndexingOnCourse,
|
||||
updateCourseSectionHighlights,
|
||||
setSectionOrderList,
|
||||
setVideoSharingOption,
|
||||
setCourseItemOrderList,
|
||||
copyBlockToClipboard,
|
||||
pasteBlock,
|
||||
dismissNotification,
|
||||
} from './api';
|
||||
import {
|
||||
addSection,
|
||||
addSubsection,
|
||||
fetchOutlineIndexSuccess,
|
||||
updateOutlineIndexLoadingStatus,
|
||||
updateReindexLoadingStatus,
|
||||
updateStatusBar,
|
||||
updateCourseActions,
|
||||
fetchStatusBarChecklistSuccess,
|
||||
fetchStatusBarSelPacedSuccess,
|
||||
updateSavingStatus,
|
||||
updateSectionList,
|
||||
updateFetchSectionLoadingStatus,
|
||||
deleteSection,
|
||||
deleteSubsection,
|
||||
deleteUnit,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
reorderSubsectionList,
|
||||
reorderUnitList,
|
||||
updateClipboardContent,
|
||||
} from './slice';
|
||||
|
||||
export function fetchCourseOutlineIndexQuery(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const outlineIndex = await getCourseOutlineIndex(courseId);
|
||||
const {
|
||||
courseReleaseDate,
|
||||
courseStructure: {
|
||||
highlightsEnabledForMessaging,
|
||||
videoSharingEnabled,
|
||||
videoSharingOptions,
|
||||
actions,
|
||||
},
|
||||
} = outlineIndex;
|
||||
dispatch(fetchOutlineIndexSuccess(outlineIndex));
|
||||
dispatch(updateStatusBar({
|
||||
courseReleaseDate,
|
||||
highlightsEnabledForMessaging,
|
||||
videoSharingOptions,
|
||||
videoSharingEnabled,
|
||||
}));
|
||||
dispatch(updateCourseActions(actions));
|
||||
|
||||
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseLaunchQuery({
|
||||
courseId,
|
||||
gradedOnly = true,
|
||||
validateOras = true,
|
||||
all = true,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = await getCourseLaunch({
|
||||
courseId, gradedOnly, validateOras, all,
|
||||
});
|
||||
dispatch(fetchStatusBarSelPacedSuccess({ isSelfPaced: data.isSelfPaced }));
|
||||
dispatch(fetchStatusBarChecklistSuccess(getCourseLaunchChecklist(data)));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseBestPracticesQuery({
|
||||
courseId,
|
||||
excludeGraded = true,
|
||||
all = true,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = await getCourseBestPractices({ courseId, excludeGraded, all });
|
||||
dispatch(fetchStatusBarChecklistSuccess(getCourseBestPracticesChecklist(data)));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function enableCourseHighlightsEmailsQuery(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await enableCourseHighlightsEmails(courseId);
|
||||
dispatch(fetchCourseOutlineIndexQuery(courseId));
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setVideoSharingOptionQuery(courseId, option) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await setVideoSharingOption(courseId, option);
|
||||
dispatch(updateStatusBar({ videoSharingOptions: option }));
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseReindexQuery(courseId, reindexLink) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateReindexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await restartIndexingOnCourse(reindexLink);
|
||||
dispatch(updateReindexLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
dispatch(updateReindexLoadingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseSectionQuery(sectionId, shouldScroll = false) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const data = await getCourseItem(sectionId);
|
||||
data.shouldScroll = shouldScroll;
|
||||
dispatch(updateSectionList(data));
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCourseSectionHighlightsQuery(sectionId, highlights) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await updateCourseSectionHighlights(sectionId, highlights).then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery(sectionId));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function publishCourseItemQuery(itemId, sectionId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await publishCourseSection(itemId).then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery(sectionId));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseItemQuery(sectionId, configureFn) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await configureFn().then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery(sectionId));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, startDatetime) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
sectionId,
|
||||
async () => configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseSubsectionQuery(
|
||||
itemId,
|
||||
sectionId,
|
||||
isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
graderType,
|
||||
dueDate,
|
||||
isTimeLimited,
|
||||
isProctoredExam,
|
||||
isOnboardingExam,
|
||||
isPracticeExam,
|
||||
examReviewRules,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDue,
|
||||
showCorrectness,
|
||||
isPrereq,
|
||||
prereqUsageKey,
|
||||
prereqMinScore,
|
||||
prereqMinCompletion,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
sectionId,
|
||||
async () => configureCourseSubsection(
|
||||
itemId,
|
||||
isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
graderType,
|
||||
dueDate,
|
||||
isTimeLimited,
|
||||
isProctoredExam,
|
||||
isOnboardingExam,
|
||||
isPracticeExam,
|
||||
examReviewRules,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDue,
|
||||
showCorrectness,
|
||||
isPrereq,
|
||||
prereqUsageKey,
|
||||
prereqMinScore,
|
||||
prereqMinCompletion,
|
||||
),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseUnitQuery(itemId, sectionId, isVisibleToStaffOnly, groupAccess) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
sectionId,
|
||||
async () => configureCourseUnit(itemId, isVisibleToStaffOnly, groupAccess),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function editCourseItemQuery(itemId, sectionId, displayName) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await editItemDisplayName(itemId, displayName).then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery(sectionId));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to delete course item, see below wrapper funcs for specific implementations.
|
||||
* @param {string} itemId
|
||||
* @param {() => {}} deleteItemFn
|
||||
* @returns {}
|
||||
*/
|
||||
function deleteCourseItemQuery(itemId, deleteItemFn) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
|
||||
|
||||
try {
|
||||
await deleteCourseItem(itemId);
|
||||
dispatch(deleteItemFn());
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseSectionQuery(sectionId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteCourseItemQuery(
|
||||
sectionId,
|
||||
() => deleteSection({ itemId: sectionId }),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseSubsectionQuery(subsectionId, sectionId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteCourseItemQuery(
|
||||
subsectionId,
|
||||
() => deleteSubsection({ itemId: subsectionId, sectionId }),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseUnitQuery(unitId, subsectionId, sectionId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteCourseItemQuery(
|
||||
unitId,
|
||||
() => deleteUnit({ itemId: unitId, subsectionId, sectionId }),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to duplicate any course item. See wrapper functions below for specific implementations.
|
||||
* @param {string} itemId
|
||||
* @param {string} parentLocator
|
||||
* @param {(locator) => Promise<any>} duplicateFn
|
||||
* @returns {}
|
||||
*/
|
||||
function duplicateCourseItemQuery(itemId, parentLocator, duplicateFn) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating));
|
||||
|
||||
try {
|
||||
await duplicateCourseItem(itemId, parentLocator).then(async (result) => {
|
||||
if (result) {
|
||||
await duplicateFn(result.locator);
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateSectionQuery(sectionId, courseBlockId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
sectionId,
|
||||
courseBlockId,
|
||||
async (locator) => {
|
||||
const duplicatedItem = await getCourseItem(locator);
|
||||
// Page should scroll to newly duplicated item.
|
||||
duplicatedItem.shouldScroll = true;
|
||||
dispatch(duplicateSection({ id: sectionId, duplicatedItem }));
|
||||
},
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateSubsectionQuery(subsectionId, sectionId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
subsectionId,
|
||||
sectionId,
|
||||
async () => dispatch(fetchCourseSectionQuery(sectionId, true)),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateUnitQuery(unitId, subsectionId, sectionId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
unitId,
|
||||
subsectionId,
|
||||
async () => dispatch(fetchCourseSectionQuery(sectionId, true)),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to add any course item. See wrapper functions below for specific implementations.
|
||||
* @param {string} parentLocator
|
||||
* @param {string} category
|
||||
* @param {string} displayName
|
||||
* @param {(data) => {}} addItemFn
|
||||
* @returns {}
|
||||
*/
|
||||
function addNewCourseItemQuery(parentLocator, category, displayName, addItemFn) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await addNewCourseItem(
|
||||
parentLocator,
|
||||
category,
|
||||
displayName,
|
||||
).then(async (result) => {
|
||||
if (result) {
|
||||
await addItemFn(result);
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function addNewSectionQuery(parentLocator) {
|
||||
return async (dispatch) => {
|
||||
dispatch(addNewCourseItemQuery(
|
||||
parentLocator,
|
||||
COURSE_BLOCK_NAMES.chapter.id,
|
||||
COURSE_BLOCK_NAMES.chapter.name,
|
||||
async (result) => {
|
||||
const data = await getCourseItem(result.locator);
|
||||
// Page should scroll to newly created section.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addSection(data));
|
||||
},
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function addNewSubsectionQuery(parentLocator) {
|
||||
return async (dispatch) => {
|
||||
dispatch(addNewCourseItemQuery(
|
||||
parentLocator,
|
||||
COURSE_BLOCK_NAMES.sequential.id,
|
||||
COURSE_BLOCK_NAMES.sequential.name,
|
||||
async (result) => {
|
||||
const data = await getCourseItem(result.locator);
|
||||
// Page should scroll to newly created subsection.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addSubsection({ parentLocator, data }));
|
||||
},
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function addNewUnitQuery(parentLocator, callback) {
|
||||
return async (dispatch) => {
|
||||
dispatch(addNewCourseItemQuery(
|
||||
parentLocator,
|
||||
COURSE_BLOCK_NAMES.vertical.id,
|
||||
COURSE_BLOCK_NAMES.vertical.name,
|
||||
async (result) => callback(result.locator),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function setSectionOrderListQuery(courseId, sectionListIds, restoreCallback) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await setSectionOrderList(courseId, sectionListIds).then(async (result) => {
|
||||
if (result) {
|
||||
dispatch(reorderSectionList(sectionListIds));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
restoreCallback();
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setSubsectionOrderListQuery(sectionId, subsectionListIds, restoreCallback) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await setCourseItemOrderList(sectionId, subsectionListIds).then(async (result) => {
|
||||
if (result) {
|
||||
dispatch(reorderSubsectionList({ sectionId, subsectionListIds }));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
restoreCallback();
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setUnitOrderListQuery(sectionId, subsectionId, unitListIds, restoreCallback) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await setCourseItemOrderList(subsectionId, unitListIds).then(async (result) => {
|
||||
if (result) {
|
||||
dispatch(reorderUnitList({ sectionId, subsectionId, unitListIds }));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
restoreCallback();
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setClipboardContent(usageKey, broadcastClipboard) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying));
|
||||
|
||||
try {
|
||||
await copyBlockToClipboard(usageKey).then(async (result) => {
|
||||
const status = result?.content?.status;
|
||||
if (status === 'ready') {
|
||||
dispatch(updateClipboardContent(result));
|
||||
broadcastClipboard(result);
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
} else {
|
||||
throw new Error(`Unexpected clipboard status "${status}" in successful API response.`);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function pasteClipboardContent(parentLocator, sectionId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting));
|
||||
|
||||
try {
|
||||
await pasteBlock(parentLocator).then(async (result) => {
|
||||
if (result) {
|
||||
dispatch(fetchCourseSectionQuery(sectionId, true));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function dismissNotificationQuery(url) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
|
||||
try {
|
||||
await dismissNotification(url).then(async () => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
53
src/course-outline/delete-modal/DeleteModal.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
ActionRow,
|
||||
Button,
|
||||
AlertModal,
|
||||
} from '@edx/paragon';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import { getCurrentItem } from '../data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
const DeleteModal = ({ isOpen, close, onDeleteSubmit }) => {
|
||||
const intl = useIntl();
|
||||
let { category } = useSelector(getCurrentItem);
|
||||
category = COURSE_BLOCK_NAMES[category]?.name.toLowerCase();
|
||||
|
||||
return (
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.title, { category })}
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button variant="tertiary" onClick={close}>
|
||||
{intl.formatMessage(messages.cancelButton)}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="delete-confirm-button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onDeleteSubmit();
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.deleteButton, { category })}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
<p>{intl.formatMessage(messages.description, { category })}</p>
|
||||
</AlertModal>
|
||||
);
|
||||
};
|
||||
|
||||
DeleteModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
close: PropTypes.func.isRequired,
|
||||
onDeleteSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default DeleteModal;
|
||||
90
src/course-outline/delete-modal/DeleteModal.test.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import DeleteModal from './DeleteModal';
|
||||
import messages from './messages';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const onDeleteSubmitMock = jest.fn();
|
||||
const closeMock = jest.fn();
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform/i18n'),
|
||||
useIntl: () => ({
|
||||
formatMessage: (message) => message.defaultMessage,
|
||||
}),
|
||||
}));
|
||||
|
||||
const currentItemMock = {
|
||||
displayName: 'Delete',
|
||||
};
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<DeleteModal
|
||||
isOpen
|
||||
close={closeMock}
|
||||
onDeleteSubmit={onDeleteSubmitMock}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<DeleteModal />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
useSelector.mockReturnValue(currentItemMock);
|
||||
});
|
||||
|
||||
it('render DeleteModal component correctly', () => {
|
||||
const { getByText, getByRole } = renderComponent();
|
||||
|
||||
expect(getByText(messages.title.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.description.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onDeleteSubmit function when the "Delete" button is clicked', () => {
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const okButton = getByRole('button', { name: messages.deleteButton.defaultMessage });
|
||||
fireEvent.click(okButton);
|
||||
expect(onDeleteSubmitMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls the close function when the "Cancel" button is clicked', () => {
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const cancelButton = getByRole('button', { name: messages.cancelButton.defaultMessage });
|
||||
fireEvent.click(cancelButton);
|
||||
expect(closeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
22
src/course-outline/delete-modal/messages.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'course-authoring.course-outline.delete-modal.title',
|
||||
defaultMessage: 'Delete this {category}?',
|
||||
},
|
||||
description: {
|
||||
id: 'course-authoring.course-outline.delete-modal.description',
|
||||
defaultMessage: 'Deleting this {category} is permanent and cannot be undone.',
|
||||
},
|
||||
deleteButton: {
|
||||
id: 'course-authoring.course-outline.delete-modal.button.delete',
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'course-authoring.course-outline.delete-modal.button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||