Compare commits
4 Commits
CourseRole
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80f93fe5b1 | ||
|
|
68b71f2c33 | ||
|
|
8b656b5895 | ||
|
|
a49bff03dc |
13
.env
@@ -16,27 +16,14 @@ LOGO_URL=''
|
||||
LOGO_WHITE_URL=''
|
||||
LOGOUT_URL=null
|
||||
MARKETING_SITE_BASE_URL=''
|
||||
TERMS_OF_SERVICE_URL=''
|
||||
PRIVACY_POLICY_URL=''
|
||||
ORDER_HISTORY_URL=''
|
||||
PUBLISHER_BASE_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=''
|
||||
STUDIO_SHORT_NAME='Studio'
|
||||
SUPPORT_EMAIL=''
|
||||
SUPPORT_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
ENABLE_ACCESSIBILITY_PAGE=false
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_UNIT_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=false
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=false
|
||||
INVITE_STUDENTS_EMAIL_TO=''
|
||||
AI_TRANSLATIONS_BASE_URL=''
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
NODE_ENV='development'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2001'
|
||||
BASE_URL='localhost:2001'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL=
|
||||
@@ -16,29 +16,16 @@ LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
TERMS_OF_SERVICE_URL=
|
||||
PRIVACY_POLICY_URL=
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
PORT=2001
|
||||
PUBLISHER_BASE_URL=
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='Your Plaform Name Here'
|
||||
SITE_NAME='edX'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
STUDIO_SHORT_NAME='Studio'
|
||||
SUPPORT_EMAIL=
|
||||
SUPPORT_EMAIL='support@example.com'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_ACCESSIBILITY_PAGE=false
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_UNIT_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=true
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2001'
|
||||
BASE_URL='localhost:2001'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
@@ -22,15 +22,9 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
STUDIO_SHORT_NAME='Studio'
|
||||
SUPPORT_EMAIL='support@example.com'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_UNIT_PAGE=true
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
|
||||
10
.eslintrc.js
@@ -1,8 +1,6 @@
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig(
|
||||
'eslint',
|
||||
module.exports = createConfig('eslint',
|
||||
{
|
||||
rules: {
|
||||
'jsx-a11y/label-has-associated-control': [2, {
|
||||
@@ -10,8 +8,6 @@ module.exports = createConfig(
|
||||
}],
|
||||
'template-curly-spacing': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
indent: ['error', 2],
|
||||
'no-restricted-exports': 'off',
|
||||
indent: 'off',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,4 +16,4 @@ jobs:
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
@@ -1,20 +0,0 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "label: " it tries to apply
|
||||
# the label indicated in rest of comment.
|
||||
# If the comment starts with "remove label: ", it tries
|
||||
# to remove the indicated label.
|
||||
# Note: Labels are allowed to have spaces and this script does
|
||||
# not parse spaces (as often a space is legitimate), so the command
|
||||
# "label: really long lots of words label" will apply the
|
||||
# label "really long lots of words label"
|
||||
|
||||
name: Allows for the adding and removing of labels via comment
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
add_remove_labels:
|
||||
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
|
||||
|
||||
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
||||
|
||||
12
.github/workflows/self-assign-issue.yml
vendored
@@ -1,12 +0,0 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "assign me" it assigns the author to the
|
||||
# ticket (case insensitive)
|
||||
|
||||
name: Assign comment author to ticket if they say "assign me"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
self_assign_by_comment:
|
||||
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master
|
||||
12
.github/workflows/update-browserslist-db.yml
vendored
@@ -1,12 +0,0 @@
|
||||
name: Update Browserslist DB
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-browserslist:
|
||||
uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
|
||||
|
||||
secrets:
|
||||
requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}
|
||||
11
.github/workflows/validate.yml
vendored
@@ -9,13 +9,14 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
|
||||
3
.gitignore
vendored
@@ -20,6 +20,3 @@ temp/babel-plugin-react-intl
|
||||
/temp
|
||||
/.vscode
|
||||
/module.config.js
|
||||
|
||||
# Local environment overrides
|
||||
.env.private
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"extends": ["@edx/stylelint-config-edx"],
|
||||
"rules": {
|
||||
"selector-pseudo-class-no-unknown": [true, {
|
||||
"ignorePseudoClasses": ["export"]
|
||||
}],
|
||||
"unit-no-unknown": [true, {
|
||||
"ignoreUnits": ["\\.5"]
|
||||
}],
|
||||
"property-no-vendor-prefix": [true, {
|
||||
"ignoreProperties": ["animation", "filter", "transform", "transition"]
|
||||
}],
|
||||
"value-no-vendor-prefix": [true, {
|
||||
"ignoreValues": ["fill-available"]
|
||||
}],
|
||||
"function-no-unknown": null,
|
||||
"number-leading-zero": "never",
|
||||
"no-descending-specificity": null,
|
||||
"selector-class-pattern": null,
|
||||
"scss/no-global-function-names": null,
|
||||
"color-hex-case": "upper",
|
||||
"color-hex-length": "long",
|
||||
"scss/dollar-variable-empty-line-before": null,
|
||||
"scss/dollar-variable-colon-space-after": "at-least-one-space",
|
||||
"at-rule-no-unknown": null,
|
||||
"scss/at-rule-no-unknown": true,
|
||||
"scss/at-import-partial-extension": null,
|
||||
"scss/comment-no-empty": null,
|
||||
"property-no-unknown": [true, {
|
||||
"ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"]
|
||||
}],
|
||||
"alpha-value-notation": "number"
|
||||
}
|
||||
}
|
||||
29
Makefile
Normal file → Executable file
@@ -1,21 +1,22 @@
|
||||
transifex_resource = frontend-app-course-authoring
|
||||
export TRANSIFEX_RESOURCE = ${transifex_resource}
|
||||
transifex_langs = "ar,de,de_DE,es_419,fa_IR,fr,fr_CA,hi,it,it_IT,pt,pt_PT,ru,uk,zh_CN"
|
||||
transifex_langs = "ar,fr,es_419,zh_CN"
|
||||
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
transifex_input = $(i18n)/transifex_input.json
|
||||
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
|
||||
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
|
||||
|
||||
# This directory must match .babelrc .
|
||||
transifex_temp = ./temp/babel-plugin-formatjs
|
||||
transifex_temp = ./temp/babel-plugin-react-intl
|
||||
|
||||
precommit:
|
||||
npm run lint
|
||||
npm audit
|
||||
|
||||
requirements:
|
||||
npm ci
|
||||
npm install
|
||||
|
||||
i18n.extract:
|
||||
# Pulling display strings from .jsx files into .json files...
|
||||
@@ -44,26 +45,9 @@ push_translations:
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
ifeq ($(OPENEDX_ATLAS_PULL),)
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
|
||||
else
|
||||
# Pulls translations using atlas.
|
||||
pull_translations:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull $(ATLAS_OPTIONS) \
|
||||
translations/frontend-component-ai-translations/src/i18n/messages:frontend-component-ai-translations \
|
||||
translations/frontend-lib-content-components/src/i18n/messages:frontend-lib-content-components \
|
||||
translations/frontend-platform/src/i18n/messages:frontend-platform \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring
|
||||
|
||||
$(intl_imports) frontend-component-ai-translations frontend-lib-content-components frontend-platform paragon frontend-component-footer frontend-app-course-authoring
|
||||
endif
|
||||
tx pull -f --mode reviewed --languages=$(transifex_langs)
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
@@ -75,7 +59,6 @@ validate:
|
||||
make validate-no-uncommitted-package-lock-changes
|
||||
npm run i18n_extract
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run types
|
||||
npm run test
|
||||
npm run build
|
||||
|
||||
|
||||
378
README.rst
@@ -1,300 +1,39 @@
|
||||
|Build Status| |Codecov| |license|
|
||||
|
||||
frontend-app-course-authoring
|
||||
#############################
|
||||
=============================
|
||||
|
||||
|license-badge| |status-badge| |codecov-badge|
|
||||
Please tag `@edx/teaching-and-learning <https://github.com/orgs/edx/teams/teaching-and-learning>`_ on any PRs or issues. Thanks.
|
||||
|
||||
Prerequisite
|
||||
------------
|
||||
|
||||
Purpose
|
||||
*******
|
||||
|
||||
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
|
||||
|
||||
Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below.
|
||||
|
||||
|
||||
Getting Started
|
||||
************
|
||||
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
The `devstack`_ is currently recommended as a development environment for your
|
||||
new MFE. If you start it with ``make dev.up.lms`` that should give you
|
||||
everything you need as a companion to this frontend.
|
||||
|
||||
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ to get started using it.
|
||||
|
||||
.. _Devstack: https://github.com/openedx/devstack
|
||||
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
All features that integrate into the edx-platform CMS require that the ``COURSE_AUTHORING_MICROFRONTEND_URL`` Django setting is set in the CMS environment and points to this MFE's deployment URL. This should be done automatically if you are using devstack or tutor-mfe.
|
||||
|
||||
Cloning and Startup
|
||||
===================
|
||||
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
|
||||
|
||||
Installation and Startup
|
||||
------------------------
|
||||
|
||||
1. Clone the repo:
|
||||
|
||||
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
|
||||
|
||||
2. Use node v18.x.
|
||||
|
||||
The current version of the micro-frontend build scripts support node 18.
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes an .nvmrc file to help in setting the
|
||||
correct node version via `nvm use`_.
|
||||
|
||||
3. Install npm dependencies:
|
||||
2. Install npm dependencies:
|
||||
|
||||
``cd frontend-app-course-authoring && npm install``
|
||||
|
||||
|
||||
4. Start the dev server:
|
||||
3. Start the dev server:
|
||||
|
||||
``npm start``
|
||||
|
||||
|
||||
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
|
||||
or whatever port you setup.
|
||||
|
||||
|
||||
Features
|
||||
********
|
||||
|
||||
Feature: Pages and Resources Studio Tab
|
||||
=======================================
|
||||
|
||||
Enables a "Pages & Resources" menu item in Studio, under the "Content" menu.
|
||||
|
||||
.. image:: ./docs/readme-images/feature-pages-resources.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
The following are requirements for this feature to function correctly:
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``discussions.pages_and_resources_mfe``: must be enabled for the set of users meant to access this feature.
|
||||
|
||||
* `frontend-app-learning <https://github.com/openedx/frontend-app-learning>`_: This MFE expects it to be the LMS frontend.
|
||||
* `frontend-app-discussions <https://github.com/openedx/frontend-app-discussions/>`_: This is what the "Discussions" configuration provided by this feature actually configures. Without it, discussion settings are ignored.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
In additional to the standard settings, the following local configuration items are required:
|
||||
|
||||
* ``LEARNING_BASE_URL``: points to Learning MFE; necessary so that the `View Live` button works
|
||||
* ``ENABLE_PROGRESS_GRAPH_SETTINGS``: allow enabling or disabling the learner progress graph course-wide
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
Clicking on the "Pages & Resources" menu item takes the user to the course's ``pages-and-resources`` standalone page in this MFE. (In a devstack, for instance: http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources.)
|
||||
|
||||
UX-wise, **Pages & Resources** is meant to look like a Studio tab, so reproduces Studio's header.
|
||||
|
||||
For a particular course, this page allows one to:
|
||||
|
||||
* Configure the new Discussions MFE (making this a requirement for it). This includes:
|
||||
|
||||
* Enabling/disabling the feature entirely
|
||||
* Picking a different discussion provider, while showing a comparison matrix between them:
|
||||
|
||||
* edX
|
||||
* Ed Discussion
|
||||
* InScribe
|
||||
* Piazza
|
||||
* Yellowdig
|
||||
|
||||
* Allowing to configure the selected provider
|
||||
|
||||
* Enable/Disable learner progress
|
||||
* Enable/Disable learner notes
|
||||
* Enable/Disable the learner wiki
|
||||
* Enable/Disable the LMS calculator
|
||||
* Go to the textbook management page in Studio (in a devstack: http://localhost:18010/textbooks/course-v1:edX+DemoX+Demo_Course)
|
||||
* Go to the custom page management page in Studio(in a devstack http://localhost:18010/tabs/course-v1:edX+DemoX+Demo_Course)
|
||||
|
||||
Feature: New React XBlock Editors
|
||||
=================================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-problem-editor.png
|
||||
|
||||
This allows an operator to enable the use of new React editors for the HTML, Video, and Problem XBlocks, all of which are provided here.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``new_core_editors.use_new_text_editor``: must be enabled for the new HTML Xblock editor to be used in Studio
|
||||
* ``new_core_editors.use_new_video_editor``: must be enabled for the new Video Xblock editor to be used in Studio
|
||||
* ``new_core_editors.use_new_problem_editor``: must be enabled for the new Problem Xblock editor to be used in Studio
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
In additional to the standard settings, the following local configuration item is required:
|
||||
|
||||
* ``ENABLE_NEW_EDITOR_PAGES``: must be enabled in order to actually present the new XBlock editors (on by default)
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
When a corresponding waffle flag is set, upon editing a block in Studio, the view is rendered by this MFE instead of by the XBlock's authoring view. The user remains in Studio.
|
||||
|
||||
.. note::
|
||||
|
||||
The new editors themselves are currently implemented in a repository outside ``openedx``: `frontend-lib-content-components <https://github.com/edx/frontend-lib-content-components/>`_, a dependency of this MFE. This repository is slated to be moved to the ``openedx`` org, however.
|
||||
|
||||
Feature: New Proctoring Exams View
|
||||
==================================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-proctored-exams.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Django settings:
|
||||
|
||||
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
|
||||
|
||||
* ``edx-platform`` Feature flags:
|
||||
|
||||
* ``ENABLE_EXAM_SETTINGS_HTML_VIEW``: this feature flag must be enabled for the link to the settings view to be shown
|
||||
|
||||
* `edx-exams <https://github.com/edx/edx-exams>`_: for this feature to work, the ``edx-exams`` IDA must be deployed and its API accessible by the browser
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
In additional to the standard settings, the following local configuration item is required:
|
||||
|
||||
* ``EXAMS_BASE_URL``: URL to the ``edx-exams`` deployment
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
In Studio, a new item ("Proctored Exam Settings") is added to "Other Course Settings" in the course's "Certificates" settings page. When clicked, this takes the author to the corresponding page in the Course Authoring MFE, where one can:
|
||||
|
||||
* Enable proctored exams for the course
|
||||
* Allow opting out of proctored exams
|
||||
* Select a proctoring provider
|
||||
* Enable automatic creation of Zendesk tickets for "suspicious" proctored exam attempts
|
||||
|
||||
Feature: Advanced Settings
|
||||
==========================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-advanced-settings.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_advanced_settings_page``: this feature flag must be enabled for the link to the settings view to be shown. It can be enabled on a per-course basis.
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
In Studio, the "Advanced Settings" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. The advanced settings page holds many different settings for the course, such as what features or XBlocks are enabled.
|
||||
|
||||
Feature: Files & Uploads
|
||||
==========================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-files-uploads.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_files_uploads_page``: this feature flag must be enabled for the link to the Files & Uploads page to go to the MFE. It can be enabled on a per-course basis.
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
In Studio, the "Files & Uploads" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. This page allows managing static asset files like PDFs, images, etc. used for the course.
|
||||
|
||||
Feature: Course Updates
|
||||
==========================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-course-updates.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_updates_page``: this feature flag must be enabled.
|
||||
|
||||
Feature: Import/Export Pages
|
||||
============================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-export.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_export_page``: this feature flag will change the CMS to link to the new export page.
|
||||
* ``contentstore.new_studio_mfe.use_new_import_page``: this feature flag will change the CMS to link to the new import page.
|
||||
|
||||
Feature: Tagging/Taxonomy Pages
|
||||
================================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-tagging-taxonomy-pages.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``new_studio_mfe.use_tagging_taxonomy_list_page``: this feature flag must be enabled.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
In additional to the standard settings, the following local configuration items are required:
|
||||
|
||||
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled in order to actually present the new Tagging/Taxonomy pages.
|
||||
|
||||
|
||||
Developing
|
||||
**********
|
||||
|
||||
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
|
||||
|
||||
|
||||
If your devstack includes the default Demo course, you can visit the following URLs to see content:
|
||||
|
||||
- `Pages and Resources <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources>`_
|
||||
|
||||
Troubleshooting
|
||||
========================
|
||||
|
||||
* ``npm ERR! gyp ERR! build error`` while running npm install on Macs with M1 processors: Probably due to a compatibility issue of node-canvas with M1.
|
||||
|
||||
Run ``brew install pkg-config pixman cairo pango libpng jpeg giflib librsvg`` before ``npm install`` to get the correct versions of the dependencies.
|
||||
If there is still an error, look for "no package [...] found" in the error message and install missing package via brew.
|
||||
(https://github.com/Automattic/node-canvas/issues/1733)
|
||||
|
||||
|
||||
Deploying
|
||||
*********
|
||||
- `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)
|
||||
|
||||
Production Build
|
||||
================
|
||||
----------------
|
||||
|
||||
The production build is created with ``npm run build``.
|
||||
|
||||
@@ -304,92 +43,3 @@ The production build is created with ``npm run build``.
|
||||
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
|
||||
:target: @edx/frontend-app-course-authoring
|
||||
|
||||
Internationalization
|
||||
====================
|
||||
|
||||
Please see refer to the `frontend-platform i18n howto`_ for documentation on
|
||||
internationalization.
|
||||
|
||||
.. _frontend-platform i18n howto: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
|
||||
|
||||
|
||||
Getting Help
|
||||
************
|
||||
|
||||
If you're having trouble, we have discussion forums at
|
||||
https://discuss.openedx.org where you can connect with others in the community.
|
||||
|
||||
Our real-time conversations are on Slack. You can request a `Slack
|
||||
invitation`_, then join our `community Slack workspace`_. Because this is a
|
||||
frontend repository, the best place to discuss it would be in the `#wg-frontend
|
||||
channel`_.
|
||||
|
||||
For anything non-trivial, the best path is to open an issue in this repository
|
||||
with as many details about the issue you are facing as you can provide.
|
||||
|
||||
https://github.com/openedx/frontend-app-course-authoring/issues
|
||||
|
||||
For more information about these options, see the `Getting Help`_ page.
|
||||
|
||||
.. _Slack invitation: https://openedx.org/slack
|
||||
.. _community Slack workspace: https://openedx.slack.com/
|
||||
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
|
||||
.. _Getting Help: https://openedx.org/community/connect
|
||||
|
||||
|
||||
License
|
||||
*******
|
||||
|
||||
The code in this repository is licensed under the AGPLv3 unless otherwise
|
||||
noted.
|
||||
|
||||
Please see `LICENSE <LICENSE>`_ for details.
|
||||
|
||||
|
||||
Contributing
|
||||
************
|
||||
|
||||
Contributions are very welcome. Please read `How To Contribute`_ for details.
|
||||
|
||||
.. _How To Contribute: https://openedx.org/r/how-to-contribute
|
||||
|
||||
This project is currently accepting all types of contributions, bug fixes,
|
||||
security fixes, maintenance work, or new features. However, please make sure
|
||||
to have a discussion about your new feature idea with the maintainers prior to
|
||||
beginning development to maximize the chances of your change being accepted.
|
||||
You can start a conversation by creating a new issue on this repo summarizing
|
||||
your idea.
|
||||
|
||||
|
||||
The Open edX Code of Conduct
|
||||
****************************
|
||||
|
||||
All community members are expected to follow the `Open edX Code of Conduct`_.
|
||||
|
||||
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
|
||||
|
||||
People
|
||||
******
|
||||
|
||||
The assigned maintainers for this component and other project details may be
|
||||
found in `Backstage`_. Backstage pulls this data from the ``catalog-info.yaml``
|
||||
file in this repo.
|
||||
|
||||
.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/frontend-app-course-authoring
|
||||
|
||||
|
||||
Reporting Security Issues
|
||||
*************************
|
||||
|
||||
Please do not report security issues in public, and email security@openedx.org instead.
|
||||
|
||||
.. |license-badge| image:: https://img.shields.io/github/license/openedx/frontend-app-course-authoring.svg
|
||||
:target: https://github.com/openedx/frontend-app-course-authoring/blob/master/LICENSE
|
||||
:alt: License
|
||||
|
||||
.. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen
|
||||
|
||||
.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-course-authoring/coverage.svg?branch=master
|
||||
:target: https://codecov.io/github/openedx/frontend-app-course-authoring?branch=master
|
||||
:alt: Codecov
|
||||
|
||||
@@ -8,6 +8,3 @@ coverage:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 0%
|
||||
ignore:
|
||||
- "src/grading-settings/grading-scale/react-ranger.js"
|
||||
- "src/index.js"
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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.
|
||||
@@ -1,66 +0,0 @@
|
||||
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``.
|
||||
@@ -1,62 +0,0 @@
|
||||
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. />
|
||||
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 64 KiB |
@@ -2,17 +2,16 @@ 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',
|
||||
],
|
||||
});
|
||||
|
||||
42892
package-lock.json
generated
95
package.json
@@ -11,15 +11,12 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "fedx-scripts formatjs extract",
|
||||
"stylelint": "stylelint \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
|
||||
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"start:with-theme": "paragon install-theme && npm start && npm install",
|
||||
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
|
||||
"types": "tsc --noEmit"
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
@@ -36,70 +33,52 @@
|
||||
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-build": "^11.0.0",
|
||||
"@edx/frontend-component-footer": "11.1.1",
|
||||
"@edx/frontend-lib-content-components": "^1.43.0",
|
||||
"@edx/frontend-platform": "2.5.1",
|
||||
"@edx/paragon": "20.6.1",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.28",
|
||||
"@fortawesome/free-brands-svg-icons": "5.11.2",
|
||||
"@fortawesome/free-regular-svg-icons": "5.11.2",
|
||||
"@fortawesome/free-solid-svg-icons": "5.11.2",
|
||||
"@fortawesome/react-fontawesome": "0.1.9",
|
||||
"@reduxjs/toolkit": "1.5.0",
|
||||
"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.4",
|
||||
"moment": "2.29.2",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "17.0.2",
|
||||
"react-datepicker": "^4.13.0",
|
||||
"react-dom": "17.0.2",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"react-helmet": "^6.1.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.5",
|
||||
"react-redux": "7.1.3",
|
||||
"react-responsive": "8.1.0",
|
||||
"react-router": "5.1.2",
|
||||
"react-router-dom": "5.1.2",
|
||||
"react-transition-group": "4.4.1",
|
||||
"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.2.0",
|
||||
"@edx/frontend-build": "13.0.5",
|
||||
"@edx/react-unit-test-utils": "^1.7.0",
|
||||
"@edx/browserslist-config": "1.0.0",
|
||||
"@edx/frontend-build": "^11.0.0",
|
||||
"@edx/reactifex": "^1.0.3",
|
||||
"@edx/stylelint-config-edx": "^2.3.0",
|
||||
"@edx/typescript-config": "^1.0.1",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/jest-dom": "5.16.4",
|
||||
"@testing-library/react": "12.1.1",
|
||||
"@testing-library/user-event": "^13.2.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"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
|
||||
<link rel="shortcut icon" href="<%= process.env.FAVICON_URL %>" type="image/x-icon" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base",
|
||||
"schedule:weekly",
|
||||
"schedule:daily",
|
||||
":rebaseStalePrs",
|
||||
":semanticCommits",
|
||||
":dependencyDashboard"
|
||||
":semanticCommits"
|
||||
],
|
||||
"timezone": "America/New_York",
|
||||
"patch": {
|
||||
"automerge": false
|
||||
"automerge": true
|
||||
},
|
||||
"rebaseStalePrs": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"extends": [
|
||||
"schedule:daily"
|
||||
],
|
||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||
"matchPackagePatterns": ["@edx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@edx/frontend-lib-content-components"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": false,
|
||||
"schedule": [
|
||||
"after 1am",
|
||||
"before 11pm"
|
||||
]
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,55 +1,24 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import Header from './header';
|
||||
import Header from './studio-header/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 { getCourseAppsApiStatus, getLoadingStatus } 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,
|
||||
}) => (
|
||||
<Header
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
);
|
||||
|
||||
AppHeader.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
courseNumber: PropTypes.string,
|
||||
courseOrg: PropTypes.string,
|
||||
courseTitle: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
AppHeader.defaultProps = {
|
||||
courseNumber: null,
|
||||
courseOrg: null,
|
||||
};
|
||||
|
||||
const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
export default function 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);
|
||||
@@ -58,42 +27,41 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
const courseOrg = courseDetail ? courseDetail.org : null;
|
||||
const courseTitle = courseDetail ? courseDetail.name : courseId;
|
||||
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
|
||||
const courseDetailStatus = useSelector(state => state.courseDetail.status);
|
||||
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS;
|
||||
const inProgress = useSelector(getLoadingStatus) === RequestStatus.IN_PROGRESS;
|
||||
const { pathname } = useLocation();
|
||||
const isEditor = pathname.includes('/editor');
|
||||
|
||||
if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) {
|
||||
return (
|
||||
<NotFoundAlert />
|
||||
);
|
||||
}
|
||||
if (courseAppsApiStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
|
||||
const AppHeader = () => (
|
||||
<Header
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
);
|
||||
|
||||
const AppFooter = () => (
|
||||
<div className="mt-6">
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
|
||||
{/* While V2 Editors are temporarily served from their own pages
|
||||
<div className="bg-light-200">
|
||||
{/* While V2 Editors are tempoarily served from thier own pages
|
||||
using url pattern containing /editor/,
|
||||
we shouldn't have the header and footer on these pages.
|
||||
This functionality will be removed in TNL-9591 */}
|
||||
{inProgress ? !isEditor && <Loading />
|
||||
: (!isEditor && (
|
||||
<AppHeader
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{inProgress ? !pathname.includes('/editor/') && <Loading /> : <AppHeader />}
|
||||
{children}
|
||||
{!inProgress && !isEditor && <StudioFooter />}
|
||||
{!inProgress && <AppFooter />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
CourseAuthoringPage.propTypes = {
|
||||
children: PropTypes.node,
|
||||
@@ -103,5 +71,3 @@ CourseAuthoringPage.propTypes = {
|
||||
CourseAuthoringPage.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
export default CourseAuthoringPage;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import { queryByTestId, render } from '@testing-library/react';
|
||||
|
||||
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
@@ -12,7 +12,6 @@ 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,18 +23,50 @@ jest.mock('react-router-dom', () => ({
|
||||
}));
|
||||
let axiosMock;
|
||||
let store;
|
||||
let container;
|
||||
function renderComponent() {
|
||||
const wrapper = render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</CourseAuthoringPage>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
const mockStore = async () => {
|
||||
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const courseAppsApiUrl = `${apiBaseUrl}/api/course_apps/v1/apps`;
|
||||
axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(403, {
|
||||
response: { status: 403 },
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
};
|
||||
describe('DiscussionsSettings', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
test('renders permission error in case of 403', async () => {
|
||||
await mockStore();
|
||||
renderComponent();
|
||||
expect(queryByTestId(container, 'permissionDeniedAlert')).toBeInTheDocument();
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
describe('Editor Pages Load no header', () => {
|
||||
@@ -47,6 +78,18 @@ 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();
|
||||
@@ -78,56 +121,3 @@ 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,25 +1,11 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Navigate, Routes, Route, useParams,
|
||||
} from 'react-router-dom';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { PageWrap } from '@edx/frontend-platform/react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Switch, useRouteMatch } from 'react-router';
|
||||
import { PageRoute } from '@edx/frontend-platform/react';
|
||||
import CourseAuthoringPage from './CourseAuthoringPage';
|
||||
import { PagesAndResources } from './pages-and-resources';
|
||||
import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings';
|
||||
import EditorContainer from './editors/EditorContainer';
|
||||
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
|
||||
import CustomPages from './custom-pages';
|
||||
import { FilesPage, VideosPage } from './files-and-videos';
|
||||
import { AdvancedSettings } from './advanced-settings';
|
||||
import { CourseOutline } from './course-outline';
|
||||
import ScheduleAndDetails from './schedule-and-details';
|
||||
import { GradingSettings } from './grading-settings';
|
||||
import CourseTeam from './course-team/CourseTeam';
|
||||
import { CourseUpdates } from './course-updates';
|
||||
import { CourseUnit } from './course-unit';
|
||||
import CourseExportPage from './export-page/CourseExportPage';
|
||||
import CourseImportPage from './import-page/CourseImportPage';
|
||||
import { DECODED_ROUTES } from './constants';
|
||||
|
||||
/**
|
||||
* As of this writing, these routes are mounted at a path prefixed with the following:
|
||||
@@ -37,81 +23,30 @@ import { DECODED_ROUTES } from './constants';
|
||||
* 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 = () => {
|
||||
const { courseId } = useParams();
|
||||
|
||||
export default function CourseAuthoringRoutes({ courseId }) {
|
||||
const { path } = useRouteMatch();
|
||||
return (
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<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>
|
||||
<Switch>
|
||||
<PageRoute path={`${path}/pages-and-resources`}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/proctored-exam-settings`}>
|
||||
<ProctoredExamSettings courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/editor/:blockType/:blockId`}>
|
||||
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
|
||||
&& (
|
||||
<EditorContainer
|
||||
courseId={courseId}
|
||||
/>
|
||||
)}
|
||||
</PageRoute>
|
||||
</Switch>
|
||||
</CourseAuthoringPage>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default CourseAuthoringRoutes;
|
||||
CourseAuthoringRoutes.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import React from 'react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
|
||||
import initializeStore from './store';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const pagesAndResourcesMockText = 'Pages And Resources';
|
||||
const editorContainerMockText = 'Editor Container';
|
||||
const videoSelectorContainerMockText = 'Video Selector Container';
|
||||
const customPagesMockText = 'Custom Pages';
|
||||
let store;
|
||||
const mockComponentFn = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
courseId,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the TinyMceWidget from frontend-lib-content-components
|
||||
jest.mock('@edx/frontend-lib-content-components', () => ({
|
||||
TinyMceWidget: () => <div>Widget</div>,
|
||||
Footer: () => <div>Footer</div>,
|
||||
prepareEditorRef: jest.fn(() => ({
|
||||
refReady: true,
|
||||
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('./pages-and-resources/PagesAndResources', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return pagesAndResourcesMockText;
|
||||
});
|
||||
jest.mock('./editors/EditorContainer', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return editorContainerMockText;
|
||||
});
|
||||
jest.mock('./selectors/VideoSelectorContainer', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return videoSelectorContainerMockText;
|
||||
});
|
||||
jest.mock('./custom-pages/CustomPages', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return customPagesMockText;
|
||||
});
|
||||
|
||||
describe('<CourseAuthoringRoutes>', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
fit('renders the PagesAndResources component when the pages and resources route is active', () => {
|
||||
render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/pages-and-resources']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the EditorContainer component when the course editor route is active', () => {
|
||||
render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/editor/video/block-id']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the VideoSelectorContainer component when the course videos route is active', () => {
|
||||
render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/editor/course-videos/block-id']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,301 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Container, Button, Layout, StatefulButton, TransitionReplace,
|
||||
} from '@edx/paragon';
|
||||
import { CheckCircle, Info, Warning } from '@edx/paragon/icons';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import Placeholder from '@edx/frontend-lib-content-components';
|
||||
|
||||
import AlertProctoringError from '../generic/AlertProctoringError';
|
||||
import { useModel } from '../generic/model-store';
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import { parseArrayOrObjectValues } from '../utils';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
import { fetchCourseAppSettings, updateCourseAppSetting, fetchProctoringExamErrors } from './data/thunks';
|
||||
import {
|
||||
getCourseAppSettings, getSavingStatus, getProctoringExamErrors, getSendRequestErrors, getLoadingStatus,
|
||||
} from './data/selectors';
|
||||
import SettingCard from './setting-card/SettingCard';
|
||||
import SettingsSidebar from './settings-sidebar/SettingsSidebar';
|
||||
import validateAdvancedSettingsData from './utils';
|
||||
import messages from './messages';
|
||||
import ModalError from './modal-error/ModalError';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { useUserPermissions } from '../generic/hooks';
|
||||
import { getUserPermissionsEnabled } from '../generic/data/selectors';
|
||||
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
|
||||
|
||||
const AdvancedSettings = ({ intl, courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
|
||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||
const [errorModal, showErrorModal] = useState(false);
|
||||
const [editedSettings, setEditedSettings] = useState({});
|
||||
const [errorFields, setErrorFields] = useState([]);
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
|
||||
const [isQueryPending, setIsQueryPending] = useState(false);
|
||||
const [isEditableState, setIsEditableState] = useState(false);
|
||||
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
|
||||
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
|
||||
|
||||
const { checkPermission } = useUserPermissions();
|
||||
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||
const viewOnly = checkPermission('view_course_settings');
|
||||
const showPermissionDeniedAlert = userPermissionsEnabled && (
|
||||
!checkPermission('manage_advanced_settings') && !checkPermission('view_course_settings')
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseAppSettings(courseId));
|
||||
dispatch(fetchProctoringExamErrors(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
const advancedSettingsData = useSelector(getCourseAppSettings);
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
const proctoringExamErrors = useSelector(getProctoringExamErrors);
|
||||
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
|
||||
const loadingSettingsStatus = useSelector(getLoadingStatus);
|
||||
|
||||
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
|
||||
const updateSettingsButtonState = {
|
||||
labels: {
|
||||
default: intl.formatMessage(messages.buttonSaveText),
|
||||
pending: intl.formatMessage(messages.buttonSavingText),
|
||||
},
|
||||
disabledStates: ['pending'],
|
||||
};
|
||||
const {
|
||||
proctoringErrors,
|
||||
mfeProctoredExamSettingsUrl,
|
||||
} = proctoringExamErrors;
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
setIsQueryPending(false);
|
||||
setShowSuccessAlert(true);
|
||||
setIsEditableState(false);
|
||||
setTimeout(() => setShowSuccessAlert(false), 15000);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
showSaveSettingsPrompt(false);
|
||||
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
|
||||
setErrorFields(settingsWithSendErrors);
|
||||
showErrorModal(true);
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
}
|
||||
if (showPermissionDeniedAlert) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
if (loadingSettingsStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<div className="row justify-content-center m-6">
|
||||
<Placeholder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleResetSettingsValues = () => {
|
||||
setIsEditableState(false);
|
||||
showErrorModal(false);
|
||||
setEditedSettings({});
|
||||
showSaveSettingsPrompt(false);
|
||||
};
|
||||
|
||||
const handleSettingBlur = () => {
|
||||
validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
|
||||
};
|
||||
|
||||
const handleUpdateAdvancedSettingsData = () => {
|
||||
const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
|
||||
if (isValid) {
|
||||
setIsQueryPending(true);
|
||||
} else {
|
||||
showSaveSettingsPrompt(false);
|
||||
showErrorModal(!errorModal);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInternetConnectionFailed = () => {
|
||||
setInternetConnectionError(true);
|
||||
showSaveSettingsPrompt(false);
|
||||
setShowSuccessAlert(false);
|
||||
};
|
||||
|
||||
const handleQueryProcessing = () => {
|
||||
setShowSuccessAlert(false);
|
||||
dispatch(updateCourseAppSetting(courseId, parseArrayOrObjectValues(editedSettings)));
|
||||
};
|
||||
|
||||
const handleManuallyChangeClick = (setToState) => {
|
||||
showErrorModal(setToState);
|
||||
showSaveSettingsPrompt(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container size="xl" className="advanced-settings px-4">
|
||||
<div className="setting-header mt-5">
|
||||
{(proctoringErrors?.length > 0) && (
|
||||
<AlertProctoringError
|
||||
icon={Info}
|
||||
proctoringErrorsData={proctoringErrors}
|
||||
aria-hidden="true"
|
||||
aria-labelledby={intl.formatMessage(messages.alertProctoringAriaLabelledby)}
|
||||
aria-describedby={intl.formatMessage(messages.alertProctoringDescribedby)}
|
||||
/>
|
||||
)}
|
||||
<TransitionReplace>
|
||||
{showSuccessAlert ? (
|
||||
<AlertMessage
|
||||
key={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
|
||||
show={showSuccessAlert}
|
||||
variant="success"
|
||||
icon={CheckCircle}
|
||||
title={intl.formatMessage(messages.alertSuccess)}
|
||||
description={intl.formatMessage(messages.alertSuccessDescriptions)}
|
||||
aria-hidden="true"
|
||||
aria-labelledby={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
|
||||
aria-describedby={intl.formatMessage(messages.alertSuccessAriaDescribedby)}
|
||||
/>
|
||||
) : null}
|
||||
</TransitionReplace>
|
||||
</div>
|
||||
<section className="setting-items mb-4">
|
||||
<Layout
|
||||
lg={[{ span: 9 }, { span: 3 }]}
|
||||
md={[{ span: 9 }, { span: 3 }]}
|
||||
sm={[{ span: 9 }, { span: 3 }]}
|
||||
xs={[{ span: 9 }, { span: 3 }]}
|
||||
xl={[{ span: 9 }, { span: 3 }]}
|
||||
>
|
||||
<Layout.Element>
|
||||
<SubHeader
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
title={intl.formatMessage(messages.headingTitle)}
|
||||
contentTitle={intl.formatMessage(messages.policy)}
|
||||
/>
|
||||
<article>
|
||||
<div>
|
||||
<section className="setting-items-policies">
|
||||
<div className="small">
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.policies.description"
|
||||
defaultMessage="{notice} Do not modify these policies unless you are familiar with their purpose."
|
||||
values={{ notice: <strong>Warning: </strong> }}
|
||||
/>
|
||||
</div>
|
||||
<div className="setting-items-deprecated-setting">
|
||||
<Button
|
||||
variant={showDeprecated ? 'outline-brand' : 'tertiary'}
|
||||
onClick={() => setShowDeprecated(!showDeprecated)}
|
||||
size="sm"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.deprecated.button.text"
|
||||
defaultMessage="{visibility} deprecated settings"
|
||||
values={{
|
||||
visibility:
|
||||
showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText)
|
||||
: intl.formatMessage(messages.deprecatedButtonShowText),
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="setting-items-list p-0">
|
||||
{Object.keys(advancedSettingsData).map((settingName) => {
|
||||
const settingData = advancedSettingsData[settingName];
|
||||
if (settingData.deprecated && !showDeprecated) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SettingCard
|
||||
key={settingName}
|
||||
settingData={settingData}
|
||||
name={settingName}
|
||||
showSaveSettingsPrompt={showSaveSettingsPrompt}
|
||||
saveSettingsPrompt={saveSettingsPrompt}
|
||||
setEdited={setEditedSettings}
|
||||
handleBlur={handleSettingBlur}
|
||||
isEditableState={isEditableState}
|
||||
setIsEditableState={setIsEditableState}
|
||||
disableForm={viewOnly}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</Layout.Element>
|
||||
<Layout.Element>
|
||||
<SettingsSidebar
|
||||
courseId={courseId}
|
||||
proctoredExamSettingsUrl={mfeProctoredExamSettingsUrl}
|
||||
/>
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
</section>
|
||||
</Container>
|
||||
<div className="alert-toast">
|
||||
{isQueryPending && (
|
||||
<InternetConnectionAlert
|
||||
isFailed={savingStatus === RequestStatus.FAILED}
|
||||
isQueryPending={isQueryPending}
|
||||
onQueryProcessing={handleQueryProcessing}
|
||||
onInternetConnectionFailed={handleInternetConnectionFailed}
|
||||
/>
|
||||
)}
|
||||
<AlertMessage
|
||||
show={saveSettingsPrompt}
|
||||
aria-hidden={saveSettingsPrompt}
|
||||
aria-labelledby={intl.formatMessage(messages.alertWarningAriaLabelledby)}
|
||||
aria-describedby={intl.formatMessage(messages.alertWarningAriaDescribedby)}
|
||||
role="dialog"
|
||||
actions={[
|
||||
!isQueryPending && (
|
||||
<Button variant="tertiary" onClick={handleResetSettingsValues}>
|
||||
{intl.formatMessage(messages.buttonCancelText)}
|
||||
</Button>
|
||||
),
|
||||
<StatefulButton
|
||||
key="statefulBtn"
|
||||
onClick={handleUpdateAdvancedSettingsData}
|
||||
state={isQueryPending ? RequestStatus.PENDING : 'default'}
|
||||
{...updateSettingsButtonState}
|
||||
/>,
|
||||
].filter(Boolean)}
|
||||
variant="warning"
|
||||
icon={Warning}
|
||||
title={intl.formatMessage(messages.alertWarning)}
|
||||
description={intl.formatMessage(messages.alertWarningDescriptions)}
|
||||
/>
|
||||
</div>
|
||||
<ModalError
|
||||
isError={errorModal}
|
||||
showErrorModal={(setToState) => handleManuallyChangeClick(setToState)}
|
||||
handleUndoChanges={handleResetSettingsValues}
|
||||
settingsData={advancedSettingsData}
|
||||
errorList={errorFields.length > 0 ? errorFields : []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AdvancedSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AdvancedSettings);
|
||||
@@ -1,211 +0,0 @@
|
||||
import React from 'react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import initializeStore from '../store';
|
||||
import { executeThunk } from '../utils';
|
||||
import { advancedSettingsMock } from './__mocks__';
|
||||
import { getCourseAdvancedSettingsApiUrl } from './data/api';
|
||||
import { updateCourseAppSetting } from './data/thunks';
|
||||
import AdvancedSettings from './AdvancedSettings';
|
||||
import messages from './messages';
|
||||
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
const userId = 3;
|
||||
const userPermissionsData = { permissions: ['view_course_settings', 'manage_advanced_settings'] };
|
||||
|
||||
// Mock the TextareaAutosize component
|
||||
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
<textarea
|
||||
{...props}
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
)));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
pathname: mockPathname,
|
||||
}),
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<AdvancedSettings intl={injectIntl} courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
const permissionsMockStore = async (permissions) => {
|
||||
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, permissions);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
const permissionDisabledMockStore = async () => {
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
describe('<AdvancedSettings />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
|
||||
.reply(200, advancedSettingsMock);
|
||||
permissionsMockStore(userPermissionsData);
|
||||
});
|
||||
|
||||
it('should render without errors', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
|
||||
selector: 'h2.sub-header-title',
|
||||
});
|
||||
expect(advancedSettingsElement).toBeInTheDocument();
|
||||
expect(getByText(messages.policy.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should render setting element', async () => {
|
||||
const { getByText, queryByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const advancedModuleListTitle = getByText(/Advanced Module List/i);
|
||||
expect(advancedModuleListTitle).toBeInTheDocument();
|
||||
expect(queryByText('Certificate web/html view enabled')).toBeNull();
|
||||
});
|
||||
});
|
||||
it('should change to onСhange', async () => {
|
||||
const { getByLabelText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const textarea = getByLabelText(/Advanced Module List/i);
|
||||
expect(textarea).toBeInTheDocument();
|
||||
fireEvent.change(textarea, { target: { value: '[1, 2, 3]' } });
|
||||
expect(textarea.value).toBe('[1, 2, 3]');
|
||||
});
|
||||
});
|
||||
it('should display a warning alert', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const textarea = getByLabelText(/Advanced Module List/i);
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
expect(getByText(messages.buttonCancelText.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.buttonSaveText.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.alertWarning.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.alertWarningDescriptions.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should display a tooltip on clicking on the icon', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const button = getByLabelText(/Show help text/i);
|
||||
fireEvent.click(button);
|
||||
expect(getByText(/Enter the names of the advanced modules to use in your course./i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should change deprecated button text ', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
|
||||
expect(showDeprecatedItemsBtn).toBeInTheDocument();
|
||||
fireEvent.click(showDeprecatedItemsBtn);
|
||||
expect(getByText(/Hide Deprecated Settings/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
|
||||
});
|
||||
it('should reset to default value on click on Cancel button', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
});
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
expect(textarea.value).toBe('[3, 2, 1]');
|
||||
fireEvent.click(getByText(messages.buttonCancelText.defaultMessage));
|
||||
expect(textarea.value).toBe('[]');
|
||||
});
|
||||
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
});
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } });
|
||||
expect(textarea.value).toBe('[3, 2, 1,');
|
||||
fireEvent.click(getByText('Save changes'));
|
||||
fireEvent.click(getByText('Change manually'));
|
||||
expect(textarea.value).toBe('[3, 2, 1,');
|
||||
});
|
||||
it('should show success alert after save', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
});
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
expect(textarea.value).toBe('[3, 2, 1]');
|
||||
axiosMock
|
||||
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
|
||||
.reply(200, {
|
||||
...advancedSettingsMock,
|
||||
advancedModules: {
|
||||
...advancedSettingsMock.advancedModules,
|
||||
value: [3, 2, 1],
|
||||
},
|
||||
});
|
||||
fireEvent.click(getByText('Save changes'));
|
||||
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
|
||||
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
|
||||
});
|
||||
it('should shows the PermissionDeniedAlert when there are not the right user permissions', async () => {
|
||||
const permissionsData = { permissions: ['view'] };
|
||||
await permissionsMockStore(permissionsData);
|
||||
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
|
||||
expect(permissionDeniedAlert).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should not show the PermissionDeniedAlert when the User Permissions Flag is not enabled', async () => {
|
||||
await permissionDisabledMockStore();
|
||||
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
|
||||
expect(permissionDeniedAlert).not.toBeInTheDocument();
|
||||
});
|
||||
it('should be view only if the permission is set for viewOnly', async () => {
|
||||
const permissions = { permissions: ['view_course_settings'] };
|
||||
await permissionsMockStore(permissions);
|
||||
const { getByLabelText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
expect(getByLabelText('Advanced Module List')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
module.exports = {
|
||||
advancedModules: {
|
||||
deprecated: false,
|
||||
displayName: 'Advanced Module List',
|
||||
help: 'Enter the names of the advanced modules to use in your course.',
|
||||
hideOnEnabledPublisher: false,
|
||||
value: [],
|
||||
},
|
||||
certHtmlViewEnabled: {
|
||||
deprecated: true,
|
||||
display_name: 'Certificate web/html view enabled',
|
||||
help: 'If true, certificate Web/HTML views are enabled for the course.',
|
||||
hide_on_enabled_publisher: false,
|
||||
value: true,
|
||||
},
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as advancedSettingsMock } from './advancedSettings';
|
||||
@@ -1,41 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { convertObjectToSnakeCase } from '../../utils';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getCourseAdvancedSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings/${courseId}`;
|
||||
const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/proctoring_errors/`;
|
||||
|
||||
/**
|
||||
* Get's advanced setting for a course.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getCourseAdvancedSettings(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates advanced setting for a course.
|
||||
* @param {string} courseId
|
||||
* @param {object} settings
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function updateCourseAdvancedSettings(courseId, settings) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets proctoring exam errors.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getProctoringExamErrors(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export const getLoadingStatus = (state) => state.advancedSettings.loadingStatus;
|
||||
export const getCourseAppSettings = state => state.advancedSettings.courseAppSettings;
|
||||
export const getSavingStatus = (state) => state.advancedSettings.savingStatus;
|
||||
export const getProctoringExamErrors = (state) => state.advancedSettings.proctoringErrors;
|
||||
export const getSendRequestErrors = (state) => state.advancedSettings.sendRequestErrors.developer_message;
|
||||
@@ -1,48 +0,0 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'advancedSettings',
|
||||
initialState: {
|
||||
loadingStatus: RequestStatus.IN_PROGRESS,
|
||||
savingStatus: '',
|
||||
courseAppSettings: {},
|
||||
proctoringErrors: {},
|
||||
sendRequestErrors: {},
|
||||
},
|
||||
reducers: {
|
||||
updateLoadingStatus: (state, { payload }) => {
|
||||
state.loadingStatus = payload.status;
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
fetchCourseAppsSettingsSuccess: (state, { payload }) => {
|
||||
Object.assign(state.courseAppSettings, payload);
|
||||
},
|
||||
updateCourseAppsSettingsSuccess: (state, { payload }) => {
|
||||
Object.assign(state.courseAppSettings, payload);
|
||||
},
|
||||
getDataSendErrors: (state, { payload }) => {
|
||||
Object.assign(state.sendRequestErrors, payload);
|
||||
},
|
||||
fetchProctoringExamErrorsSuccess: (state, { payload }) => {
|
||||
Object.assign(state.proctoringErrors, payload);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
getDataSendErrors,
|
||||
fetchCourseAppsSettingsSuccess,
|
||||
updateCourseAppsSettingsSuccess,
|
||||
fetchProctoringExamErrorsSuccess,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
@@ -1,85 +0,0 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import {
|
||||
getCourseAdvancedSettings,
|
||||
updateCourseAdvancedSettings,
|
||||
getProctoringExamErrors,
|
||||
} from './api';
|
||||
import {
|
||||
fetchCourseAppsSettingsSuccess,
|
||||
updateCourseAppsSettingsSuccess,
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
fetchProctoringExamErrorsSuccess,
|
||||
getDataSendErrors,
|
||||
} from './slice';
|
||||
|
||||
export function fetchCourseAppSettings(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const settingValues = await getCourseAdvancedSettings(courseId);
|
||||
const sortedDisplayName = [];
|
||||
Object.values(settingValues).forEach(value => {
|
||||
const { displayName } = value;
|
||||
sortedDisplayName.push(displayName);
|
||||
});
|
||||
const sortedSettingValues = {};
|
||||
sortedDisplayName.sort().forEach((displayName => {
|
||||
Object.entries(settingValues).forEach(([key, value]) => {
|
||||
if (value.displayName === displayName) {
|
||||
sortedSettingValues[key] = value;
|
||||
}
|
||||
});
|
||||
}));
|
||||
dispatch(fetchCourseAppsSettingsSuccess(sortedSettingValues));
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 403) {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED }));
|
||||
} else {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCourseAppSetting(courseId, settings) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const settingValues = await updateCourseAdvancedSettings(courseId, settings);
|
||||
dispatch(updateCourseAppsSettingsSuccess(settingValues));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
let errorData;
|
||||
try {
|
||||
const { customAttributes: { httpErrorResponseData } } = error;
|
||||
errorData = JSON.parse(httpErrorResponseData);
|
||||
} catch (err) {
|
||||
errorData = {};
|
||||
}
|
||||
|
||||
dispatch(getDataSendErrors(errorData));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchProctoringExamErrors(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const settingValues = await getProctoringExamErrors(courseId);
|
||||
dispatch(fetchProctoringExamErrorsSuccess(settingValues));
|
||||
return true;
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as AdvancedSettings } from './AdvancedSettings';
|
||||
@@ -1,86 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
headingTitle: {
|
||||
id: 'course-authoring.advanced-settings.heading.title',
|
||||
defaultMessage: 'Advanced settings',
|
||||
},
|
||||
headingSubtitle: {
|
||||
id: 'course-authoring.advanced-settings.heading.subtitle',
|
||||
defaultMessage: 'Settings',
|
||||
},
|
||||
policy: {
|
||||
id: 'course-authoring.advanced-settings.policies.title',
|
||||
defaultMessage: 'Manual policy definition',
|
||||
},
|
||||
alertWarning: {
|
||||
id: 'course-authoring.advanced-settings.alert.warning',
|
||||
defaultMessage: "You've made some changes",
|
||||
},
|
||||
alertWarningDescriptions: {
|
||||
id: 'course-authoring.advanced-settings.alert.warning.descriptions',
|
||||
defaultMessage: 'Your changes will not take effect until you save your progress. Take care with key and value formatting, as validation is not implemented.',
|
||||
},
|
||||
alertSuccess: {
|
||||
id: 'course-authoring.advanced-settings.alert.success',
|
||||
defaultMessage: 'Your policy changes have been saved.',
|
||||
},
|
||||
alertSuccessDescriptions: {
|
||||
id: 'course-authoring.advanced-settings.alert.success.descriptions',
|
||||
defaultMessage: 'No validation is performed on policy keys or value pairs. If you are having difficulties, check your formatting.',
|
||||
},
|
||||
alertProctoringError: {
|
||||
id: 'course-authoring.advanced-settings.alert.proctoring.error',
|
||||
defaultMessage: 'This course has protected exam setting that are incomplete or invalid.',
|
||||
},
|
||||
alertProctoringErrorDescriptions: {
|
||||
id: 'course-authoring.advanced-settings.alert.proctoring.error.descriptions',
|
||||
defaultMessage: 'You will be unable to make changes until the following setting are updated on the page below.',
|
||||
},
|
||||
buttonSaveText: {
|
||||
id: 'course-authoring.advanced-settings.alert.button.save',
|
||||
defaultMessage: 'Save changes',
|
||||
},
|
||||
buttonSavingText: {
|
||||
id: 'course-authoring.advanced-settings.alert.button.saving',
|
||||
defaultMessage: 'Saving',
|
||||
},
|
||||
buttonCancelText: {
|
||||
id: 'course-authoring.advanced-settings.alert.button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
deprecatedButtonShowText: {
|
||||
id: 'course-authoring.advanced-settings.deprecated.button.show',
|
||||
defaultMessage: 'Show',
|
||||
},
|
||||
deprecatedButtonHideText: {
|
||||
id: 'course-authoring.advanced-settings.deprecated.button.hide',
|
||||
defaultMessage: 'Hide',
|
||||
},
|
||||
alertWarningAriaLabelledby: {
|
||||
id: 'course-authoring.advanced-settings.alert.warning.aria.labelledby',
|
||||
defaultMessage: 'notification-warning-title',
|
||||
},
|
||||
alertWarningAriaDescribedby: {
|
||||
id: 'course-authoring.advanced-settings.alert.warning.aria.describedby',
|
||||
defaultMessage: 'notification-warning-description',
|
||||
},
|
||||
alertSuccessAriaLabelledby: {
|
||||
id: 'course-authoring.advanced-settings.alert.success.aria.labelledby',
|
||||
defaultMessage: 'alert-confirmation-title',
|
||||
},
|
||||
alertSuccessAriaDescribedby: {
|
||||
id: 'course-authoring.advanced-settings.alert.success.aria.describedby',
|
||||
defaultMessage: 'alert-confirmation-description',
|
||||
},
|
||||
alertProctoringAriaLabelledby: {
|
||||
id: 'course-authoring.advanced-settings.alert.proctoring.error.aria.labelledby',
|
||||
defaultMessage: 'alert-danger-title',
|
||||
},
|
||||
alertProctoringDescribedby: {
|
||||
id: 'course-authoring.advanced-settings.alert.proctoring.error.aria.describedby',
|
||||
defaultMessage: 'alert-danger-description',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,63 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ActionRow, AlertModal, Button } from '@edx/paragon';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ModalErrorListItem from './ModalErrorListItem';
|
||||
import messages from './messages';
|
||||
|
||||
const ModalError = ({
|
||||
intl, isError, handleUndoChanges, showErrorModal, errorList, settingsData,
|
||||
}) => (
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.modalErrorTitle)}
|
||||
isOpen={isError}
|
||||
variant="danger"
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => showErrorModal(!isError)}
|
||||
>
|
||||
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
|
||||
</Button>
|
||||
<Button onClick={handleUndoChanges}>
|
||||
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.modal.error.description"
|
||||
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
|
||||
Please check the following validation feedbacks and reflect them in your course settings:"
|
||||
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
|
||||
/>
|
||||
</p>
|
||||
<hr />
|
||||
<ul className="p-0">
|
||||
{errorList.map((settingName) => (
|
||||
<ModalErrorListItem
|
||||
key={settingName.key}
|
||||
settingName={settingName}
|
||||
settingsData={settingsData}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</AlertModal>
|
||||
);
|
||||
|
||||
ModalError.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
isError: PropTypes.bool.isRequired,
|
||||
handleUndoChanges: PropTypes.func.isRequired,
|
||||
showErrorModal: PropTypes.func.isRequired,
|
||||
errorList: PropTypes.arrayOf(PropTypes.shape({
|
||||
key: PropTypes.string,
|
||||
message: PropTypes.string,
|
||||
})).isRequired,
|
||||
settingsData: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ModalError);
|
||||
@@ -1,58 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ModalError from './ModalError';
|
||||
import messages from './messages';
|
||||
|
||||
const handleUndoChangesMock = jest.fn();
|
||||
const showErrorModalMock = jest.fn();
|
||||
|
||||
const errorList = [
|
||||
{ key: 'setting1', message: 'Error 1' },
|
||||
{ key: 'setting2', message: 'Error 2' },
|
||||
];
|
||||
|
||||
const settingsData = {
|
||||
setting1: 'value1',
|
||||
setting2: 'value2',
|
||||
};
|
||||
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en">
|
||||
<ModalError
|
||||
isError
|
||||
handleUndoChanges={handleUndoChangesMock}
|
||||
showErrorModal={showErrorModalMock}
|
||||
errorList={errorList}
|
||||
settingsData={settingsData}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('<ModalError />', () => {
|
||||
it('calls handleUndoChanges when "Undo changes" button is clicked', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const undoChangesButton = getByText(messages.modalErrorButtonUndoChanges.defaultMessage);
|
||||
fireEvent.click(undoChangesButton);
|
||||
expect(handleUndoChangesMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('calls showErrorModal when "Change manually" button is clicked', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const changeManuallyButton = getByText(messages.modalErrorButtonChangeManually.defaultMessage);
|
||||
fireEvent.click(changeManuallyButton);
|
||||
expect(showErrorModalMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('renders error message with correct values', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText(/There was/i)).toBeInTheDocument();
|
||||
expect(getByText(/2 validation error/i)).toBeInTheDocument();
|
||||
expect(getByText(/while trying to save the course settings in the database. Please check the following validation feedbacks and reflect them in your course settings:/i)).toBeInTheDocument();
|
||||
expect(getByText(messages.modalErrorTitle.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
it('renders correct number of errors', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText('Error 1')).toBeInTheDocument();
|
||||
expect(getByText('Error 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Icon } from '@edx/paragon';
|
||||
import { Error } from '@edx/paragon/icons';
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
import { transformKeysToCamelCase } from '../../utils';
|
||||
|
||||
const ModalErrorListItem = ({ settingName, settingsData }) => {
|
||||
const { displayName } = settingsData[transformKeysToCamelCase(settingName)];
|
||||
return (
|
||||
<li className="modal-error-item">
|
||||
<Alert variant="danger">
|
||||
<h4 className="modal-error-item-title">
|
||||
<Icon src={Error} />{capitalize(displayName)}:
|
||||
</h4>
|
||||
<p className="m-0">{settingName.message}</p>
|
||||
</Alert>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
ModalErrorListItem.propTypes = {
|
||||
settingName: PropTypes.shape({
|
||||
key: PropTypes.string,
|
||||
message: PropTypes.string,
|
||||
}).isRequired,
|
||||
settingsData: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default ModalErrorListItem;
|
||||
@@ -1,34 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ModalErrorListItem from './ModalErrorListItem';
|
||||
|
||||
const settingName = {
|
||||
key: 'exampleKey',
|
||||
message: 'Error message',
|
||||
};
|
||||
|
||||
const settingsData = {
|
||||
exampleKey: {
|
||||
displayName: 'Error field',
|
||||
},
|
||||
};
|
||||
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en">
|
||||
<ModalErrorListItem settingName={settingName} settingsData={settingsData} />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('<ModalErrorListItem />', () => {
|
||||
it('renders the display name and error message', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText('Error field:')).toBeInTheDocument();
|
||||
expect(getByText('Error message')).toBeInTheDocument();
|
||||
});
|
||||
it('renders the alert with variant "danger"', () => {
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
expect(getByRole('alert')).toHaveClass('alert-danger');
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
modalErrorTitle: {
|
||||
id: 'course-authoring.advanced-settings.modal.error.title',
|
||||
defaultMessage: 'Validation error while saving',
|
||||
},
|
||||
modalErrorButtonChangeManually: {
|
||||
id: 'course-authoring.advanced-settings.modal.error.btn.change-manually',
|
||||
defaultMessage: 'Change manually',
|
||||
},
|
||||
modalErrorButtonUndoChanges: {
|
||||
id: 'course-authoring.advanced-settings.modal.error.btn.undo-changes',
|
||||
defaultMessage: 'Undo changes',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,124 +0,0 @@
|
||||
@import "variables";
|
||||
|
||||
.advanced-settings {
|
||||
.help-sidebar {
|
||||
margin-top: 8.75rem;
|
||||
}
|
||||
|
||||
.setting-items-policies {
|
||||
.setting-items-deprecated-setting {
|
||||
float: right;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.instructions,
|
||||
strong {
|
||||
color: $text-color-base;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-card {
|
||||
margin-bottom: 1.75rem;
|
||||
|
||||
.pgn__card-header .pgn__card-header-title-md {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-toast {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 0 .625rem;
|
||||
z-index: $zindex-modal;
|
||||
}
|
||||
|
||||
.alert-proctoring-error {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.setting-items-list {
|
||||
li {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
min-height: 2.75rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.pgn__card-header {
|
||||
padding: 0 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.pgn__card-status {
|
||||
padding: .625rem;
|
||||
}
|
||||
|
||||
.pgn__card-header-content {
|
||||
margin-top: 1.438rem;
|
||||
margin-bottom: 1.438rem;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-sidebar-supplementary {
|
||||
.setting-sidebar-supplementary-about {
|
||||
.setting-sidebar-supplementary-about-title {
|
||||
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
|
||||
color: $headings-color;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.setting-sidebar-supplementary-about-descriptions {
|
||||
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
|
||||
color: $text-color-base;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-sidebar-supplementary-other-links ul {
|
||||
list-style: none;
|
||||
|
||||
.setting-sidebar-supplementary-other-link {
|
||||
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
|
||||
line-height: 1.5rem;
|
||||
color: $info-500;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-sidebar-supplementary-other-title {
|
||||
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
|
||||
color: $headings-color;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-error-item {
|
||||
list-style: none;
|
||||
|
||||
.pgn__icon {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
margin-bottom: 5px;
|
||||
color: $danger;
|
||||
}
|
||||
|
||||
.modal-error-item-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-popup-content {
|
||||
max-width: 200px;
|
||||
color: $white;
|
||||
background-color: $black;
|
||||
filter: drop-shadow(0 2px 4px rgba(0 0 0 / .15));
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.pgn__modal-popup__arrow::after {
|
||||
border-top-color: $black;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
$text-color-base: $gray-700;
|
||||
@@ -1,143 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ActionRow,
|
||||
Card,
|
||||
Form,
|
||||
Icon,
|
||||
IconButton,
|
||||
ModalPopup,
|
||||
useToggle,
|
||||
} from '@edx/paragon';
|
||||
import { InfoOutline, Warning } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { capitalize } from 'lodash';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const SettingCard = ({
|
||||
name,
|
||||
settingData,
|
||||
handleBlur,
|
||||
setEdited,
|
||||
showSaveSettingsPrompt,
|
||||
saveSettingsPrompt,
|
||||
isEditableState,
|
||||
setIsEditableState,
|
||||
// injected
|
||||
intl,
|
||||
disableForm,
|
||||
}) => {
|
||||
const { deprecated, help, displayName } = settingData;
|
||||
const initialValue = JSON.stringify(settingData.value, null, 4);
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState(null);
|
||||
const [newValue, setNewValue] = useState(initialValue);
|
||||
|
||||
const handleSettingChange = (e) => {
|
||||
const { value } = e.target;
|
||||
setNewValue(e.target.value);
|
||||
if (value !== initialValue) {
|
||||
if (!saveSettingsPrompt) {
|
||||
showSaveSettingsPrompt(true);
|
||||
}
|
||||
if (!isEditableState) {
|
||||
setIsEditableState(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardBlur = () => {
|
||||
setEdited((prevEditedSettings) => ({
|
||||
...prevEditedSettings,
|
||||
[name]: newValue,
|
||||
}));
|
||||
handleBlur();
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="field-group course-advanced-policy-list-item">
|
||||
<Card className="flex-column setting-card">
|
||||
<Card.Body className="d-flex row m-0 align-items-center">
|
||||
<Card.Header
|
||||
className="col-6"
|
||||
title={(
|
||||
<ActionRow>
|
||||
{capitalize(displayName)}
|
||||
<IconButton
|
||||
ref={setTarget}
|
||||
onClick={open}
|
||||
src={InfoOutline}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.helpButtonText)}
|
||||
variant="primary"
|
||||
className=" ml-1 mr-2"
|
||||
/>
|
||||
<ModalPopup
|
||||
hasArrow
|
||||
placement="right"
|
||||
positionRef={target}
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
className="pgn__modal-popup__arrow"
|
||||
>
|
||||
<div
|
||||
className="p-2 x-small rounded modal-popup-content"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: help }}
|
||||
/>
|
||||
</ModalPopup>
|
||||
<ActionRow.Spacer />
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
<Card.Section className="col-6 flex-grow-1">
|
||||
<Form.Group className="m-0">
|
||||
<Form.Control
|
||||
as={TextareaAutosize}
|
||||
value={isEditableState ? newValue : initialValue}
|
||||
name={name}
|
||||
onChange={handleSettingChange}
|
||||
aria-label={displayName}
|
||||
onBlur={handleCardBlur}
|
||||
disabled={disableForm}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Card.Section>
|
||||
</Card.Body>
|
||||
{deprecated && (
|
||||
<Card.Status icon={Warning} variant="danger">
|
||||
{intl.formatMessage(messages.deprecated)}
|
||||
</Card.Status>
|
||||
)}
|
||||
</Card>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
SettingCard.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
settingData: PropTypes.shape({
|
||||
deprecated: PropTypes.bool,
|
||||
help: PropTypes.string,
|
||||
displayName: PropTypes.string,
|
||||
value: PropTypes.PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
PropTypes.number,
|
||||
PropTypes.object,
|
||||
PropTypes.array,
|
||||
]),
|
||||
}).isRequired,
|
||||
setEdited: PropTypes.func.isRequired,
|
||||
showSaveSettingsPrompt: PropTypes.func.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
handleBlur: PropTypes.func.isRequired,
|
||||
saveSettingsPrompt: PropTypes.bool.isRequired,
|
||||
isEditableState: PropTypes.bool.isRequired,
|
||||
setIsEditableState: PropTypes.func.isRequired,
|
||||
disableForm: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SettingCard);
|
||||
@@ -1,96 +0,0 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import SettingCard from './SettingCard';
|
||||
import messages from './messages';
|
||||
|
||||
const setEdited = jest.fn();
|
||||
const showSaveSettingsPrompt = jest.fn();
|
||||
const setIsEditableState = jest.fn();
|
||||
const handleBlur = jest.fn();
|
||||
|
||||
const settingData = {
|
||||
deprecated: false,
|
||||
help: 'This is a help message',
|
||||
displayName: 'Setting Name',
|
||||
value: 'Setting Value',
|
||||
};
|
||||
|
||||
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
<textarea
|
||||
{...props}
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
)));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en">
|
||||
<SettingCard
|
||||
intl={{}}
|
||||
isOn
|
||||
name="settingName"
|
||||
setEdited={setEdited}
|
||||
setIsEditableState={setIsEditableState}
|
||||
showSaveSettingsPrompt={showSaveSettingsPrompt}
|
||||
settingData={settingData}
|
||||
handleBlur={handleBlur}
|
||||
isEditableState
|
||||
saveSettingsPrompt={false}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('<SettingCard />', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
it('renders the setting card with the provided data', () => {
|
||||
const { getByText, getByLabelText } = render(<RootWrapper />);
|
||||
const cardTitle = getByText(/Setting Name/i);
|
||||
const input = getByLabelText(/Setting Name/i);
|
||||
expect(cardTitle).toBeInTheDocument();
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.value).toBe(JSON.stringify(settingData.value, null, 4));
|
||||
});
|
||||
it('displays the deprecated status when the setting is deprecated', () => {
|
||||
const deprecatedSettingData = { ...settingData, deprecated: true };
|
||||
const { getByText } = render(
|
||||
<IntlProvider locale="en">
|
||||
<SettingCard
|
||||
intl={{}}
|
||||
isOn
|
||||
name="settingName"
|
||||
setEdited={setEdited}
|
||||
setIsEditableState={setIsEditableState}
|
||||
showSaveSettingsPrompt={showSaveSettingsPrompt}
|
||||
settingData={deprecatedSettingData}
|
||||
handleBlur={handleBlur}
|
||||
isEditable={false}
|
||||
saveSettingsPrompt
|
||||
/>
|
||||
</IntlProvider>,
|
||||
);
|
||||
const deprecatedStatus = getByText(messages.deprecated.defaultMessage);
|
||||
expect(deprecatedStatus).toBeInTheDocument();
|
||||
});
|
||||
it('does not display the deprecated status when the setting is not deprecated', () => {
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
expect(queryByText(messages.deprecated.defaultMessage)).toBeNull();
|
||||
});
|
||||
it('calls setEdited on blur', async () => {
|
||||
const { getByLabelText } = render(<RootWrapper />);
|
||||
const inputBox = getByLabelText(/Setting Name/i);
|
||||
fireEvent.focus(inputBox);
|
||||
userEvent.clear(inputBox);
|
||||
userEvent.type(inputBox, '3, 2, 1');
|
||||
await waitFor(() => {
|
||||
expect(inputBox).toHaveValue('3, 2, 1');
|
||||
});
|
||||
await (async () => {
|
||||
expect(setEdited).toHaveBeenCalled();
|
||||
expect(handleBlur).toHaveBeenCalled();
|
||||
});
|
||||
fireEvent.focusOut(inputBox);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
deprecated: {
|
||||
id: 'course-authoring.advanced-settings.button.deprecated',
|
||||
defaultMessage: 'Deprecated',
|
||||
},
|
||||
helpButtonText: {
|
||||
id: 'course-authoring.advanced-settings.button.help',
|
||||
defaultMessage: 'Show help text',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FormattedMessage,
|
||||
injectIntl,
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { HelpSidebar } from '../../generic/help-sidebar';
|
||||
import messages from './messages';
|
||||
|
||||
const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => (
|
||||
<HelpSidebar
|
||||
courseId={courseId}
|
||||
proctoredExamSettingsUrl={proctoredExamSettingsUrl}
|
||||
showOtherSettings
|
||||
>
|
||||
<h4 className="help-sidebar-about-title">
|
||||
{intl.formatMessage(messages.about)}
|
||||
</h4>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
{intl.formatMessage(messages.aboutDescription1)}
|
||||
</p>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
{intl.formatMessage(messages.aboutDescription2)}
|
||||
</p>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.about.description-3"
|
||||
defaultMessage="{notice} When you enter strings as policy values, ensure that you use double quotation marks (“) around the string. Do not use single quotation marks (‘)."
|
||||
values={{ notice: <strong>Note:</strong> }}
|
||||
/>
|
||||
</p>
|
||||
</HelpSidebar>
|
||||
);
|
||||
|
||||
SettingsSidebar.defaultProps = {
|
||||
proctoredExamSettingsUrl: '',
|
||||
};
|
||||
|
||||
SettingsSidebar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
proctoredExamSettingsUrl: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(SettingsSidebar);
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import SettingsSidebar from './SettingsSidebar';
|
||||
import messages from './messages';
|
||||
|
||||
const courseId = 'course-123';
|
||||
let store;
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<SettingsSidebar intl={{ formatMessage: jest.fn() }} courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<SettingsSidebar />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
it('renders about and other sidebar titles correctly', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText(messages.about.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.other.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
it('renders about descriptions correctly', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const aboutThirtyDescription = getByText('When you enter strings as policy values, ensure that you use double quotation marks (“) around the string. Do not use single quotation marks (‘).');
|
||||
expect(getByText(messages.aboutDescription1.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.aboutDescription2.defaultMessage)).toBeInTheDocument();
|
||||
expect(aboutThirtyDescription).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
about: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.about.title',
|
||||
defaultMessage: 'What do advanced settings do?',
|
||||
},
|
||||
aboutDescription1: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.about.description-1',
|
||||
defaultMessage: 'Advanced settings control specific course functionality. On this page, you can edit manual policies, which are JSON-based key and value pairs that control specific course settings.',
|
||||
},
|
||||
aboutDescription2: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.about.description-2',
|
||||
defaultMessage: 'Any policies you modify here override all other information you’ve defined elsewhere in Studio. Do not edit policies unless you are familiar with both their purpose and syntax.',
|
||||
},
|
||||
other: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.other.title',
|
||||
defaultMessage: 'Other course settings',
|
||||
},
|
||||
otherCourseSettingsLinkToScheduleAndDetails: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.links.schedule-and-details',
|
||||
defaultMessage: 'Details & schedule',
|
||||
description: 'Link to Studio Details & schedule page',
|
||||
},
|
||||
otherCourseSettingsLinkToGrading: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.links.grading',
|
||||
defaultMessage: 'Grading',
|
||||
description: 'Link to Studio Grading page',
|
||||
},
|
||||
otherCourseSettingsLinkToCourseTeam: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.links.course-team',
|
||||
defaultMessage: 'Course team',
|
||||
description: 'Link to Studio Course team page',
|
||||
},
|
||||
otherCourseSettingsLinkToGroupConfigurations: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.links.group-configurations',
|
||||
defaultMessage: 'Group configurations',
|
||||
description: 'Link to Studio Group configurations page',
|
||||
},
|
||||
otherCourseSettingsLinkToProctoredExamSettings: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.links.proctored-exam-settings',
|
||||
defaultMessage: 'Proctored exam settings',
|
||||
description: 'Link to Proctored exam settings page',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Validates advanced settings data by checking if the provided settings are correctly formatted JSON.
|
||||
* It performs validation on a given object of settings, detects incorrectly formatted settings,
|
||||
* and sets error fields accordingly using the setErrorFields function.
|
||||
*
|
||||
* @param {object} settingObj - The object containing the settings to validate.
|
||||
* @param {function} setErrorFields - The function to set error fields.
|
||||
* @returns {boolean} - `true` if the data is valid, otherwise `false`.
|
||||
*/
|
||||
export default function validateAdvancedSettingsData(settingObj, setErrorFields, setEditedSettings) {
|
||||
const fieldsWithErrors = [];
|
||||
|
||||
const pushDataToErrorArray = (settingName) => {
|
||||
fieldsWithErrors.push({ key: settingName, message: 'Incorrectly formatted JSON' });
|
||||
};
|
||||
|
||||
Object.entries(settingObj).forEach(([settingName, settingValue]) => {
|
||||
try {
|
||||
JSON.parse(settingValue);
|
||||
} catch (e) {
|
||||
let targetSettingValue = settingValue;
|
||||
const firstNonWhite = settingValue.substring(0, 1);
|
||||
const isValid = !['{', '[', "'"].includes(firstNonWhite);
|
||||
|
||||
if (isValid) {
|
||||
try {
|
||||
targetSettingValue = `"${ targetSettingValue.trim() }"`;
|
||||
JSON.parse(targetSettingValue);
|
||||
setEditedSettings((prevEditedSettings) => ({
|
||||
...prevEditedSettings,
|
||||
[settingName]: targetSettingValue,
|
||||
}));
|
||||
} catch (quotedE) { /* empty */ }
|
||||
}
|
||||
|
||||
pushDataToErrorArray(settingName);
|
||||
}
|
||||
});
|
||||
|
||||
setErrorFields((prevState) => {
|
||||
if (JSON.stringify(prevState) !== JSON.stringify(fieldsWithErrors)) {
|
||||
return fieldsWithErrors;
|
||||
}
|
||||
return prevState;
|
||||
});
|
||||
|
||||
return fieldsWithErrors.length === 0;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import validateAdvancedSettingsData from './utils';
|
||||
|
||||
describe('validateAdvancedSettingsData', () => {
|
||||
it('should validate correctly formatted settings and return true', () => {
|
||||
const settingObj = {
|
||||
setting1: '{ "key": "value" }',
|
||||
setting2: '{ "key": "value" }',
|
||||
};
|
||||
const setErrorFieldsMock = jest.fn();
|
||||
const setEditedSettingsMock = jest.fn();
|
||||
const isValid = validateAdvancedSettingsData(settingObj, setErrorFieldsMock, setEditedSettingsMock);
|
||||
expect(isValid).toBe(true);
|
||||
expect(setErrorFieldsMock).toHaveBeenCalledTimes(1);
|
||||
expect(setEditedSettingsMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
it('should validate incorrectly formatted settings and set error fields', () => {
|
||||
const settingObj = {
|
||||
setting1: '{ "key": "value" }',
|
||||
setting2: 'incorrectJSON',
|
||||
setting3: '{ "key": "value" }',
|
||||
};
|
||||
const setErrorFieldsMock = jest.fn();
|
||||
const setEditedSettingsMock = jest.fn();
|
||||
const isValid = validateAdvancedSettingsData(settingObj, setErrorFieldsMock, setEditedSettingsMock);
|
||||
expect(isValid).toBe(false);
|
||||
expect(setErrorFieldsMock).toHaveBeenCalledTimes(1);
|
||||
expect(setEditedSettingsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
.form-group-custom {
|
||||
.pgn__form-label {
|
||||
font: normal $font-weight-bold .75rem/1.25rem $font-family-base;
|
||||
color: $gray-500;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.pgn__form-control-description,
|
||||
.pgn__form-text {
|
||||
font: normal $font-weight-normal .75rem/1.25rem $font-family-base;
|
||||
color: $gray-500;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.form-group-custom_isInvalid {
|
||||
input {
|
||||
border-color: $form-feedback-invalid-color;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-error {
|
||||
color: $form-feedback-invalid-color;
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker-custom {
|
||||
margin: 0;
|
||||
|
||||
.datepicker-custom-control {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: $input-font-size;
|
||||
font-weight: $input-font-weight;
|
||||
line-height: $input-line-height;
|
||||
background: $input-bg;
|
||||
border-color: $input-border-color;
|
||||
border-width: $input-border-width;
|
||||
box-shadow: $input-box-shadow;
|
||||
border-radius: $input-border-radius;
|
||||
color: $input-color;
|
||||
padding: $input-padding-y $input-padding-x;
|
||||
height: $input-height;
|
||||
resize: none;
|
||||
|
||||
&:focus,
|
||||
:focus-visible {
|
||||
color: $input-focus-color;
|
||||
background-color: $input-bg;
|
||||
border-color: $input-focus-border-color;
|
||||
box-shadow: $input-focus-box-shadow;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $input-placeholder-color;
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker-custom-control_readonly {
|
||||
border-color: transparent;
|
||||
background: $input-disabled-bg;
|
||||
}
|
||||
|
||||
.datepicker-custom-control_isInvalid {
|
||||
border-color: $form-feedback-invalid-color;
|
||||
}
|
||||
|
||||
.datepicker-custom-control-icon {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 1.188rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.text-black {
|
||||
color: $black;
|
||||
}
|
||||
|
||||
.h-200px {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.mw-300px {
|
||||
max-width: 300px;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
$text-color-base: $gray-700;
|
||||
$text-color-weak: #3E3E3C;
|
||||
@@ -1,47 +0,0 @@
|
||||
export const DATE_FORMAT = 'MM/dd/yyyy';
|
||||
export const TIME_FORMAT = 'HH:mm';
|
||||
export const DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss\\Z';
|
||||
export const COMMA_SEPARATED_DATE_FORMAT = 'MMMM D, YYYY';
|
||||
export const DEFAULT_EMPTY_WYSIWYG_VALUE = '<p> </p>';
|
||||
export const STATEFUL_BUTTON_STATES = {
|
||||
default: 'default',
|
||||
pending: 'pending',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
export const USER_ROLES = {
|
||||
admin: 'instructor',
|
||||
staff: 'staff',
|
||||
};
|
||||
|
||||
export const BADGE_STATES = {
|
||||
danger: 'danger',
|
||||
secondary: 'secondary',
|
||||
};
|
||||
|
||||
export const NOTIFICATION_MESSAGES = {
|
||||
adding: 'Adding',
|
||||
saving: 'Saving',
|
||||
duplicating: 'Duplicating',
|
||||
deleting: 'Deleting',
|
||||
copying: 'Copying',
|
||||
pasting: 'Pasting',
|
||||
empty: '',
|
||||
};
|
||||
|
||||
export const DEFAULT_TIME_STAMP = '00:00';
|
||||
|
||||
export const COURSE_CREATOR_STATES = {
|
||||
unrequested: 'unrequested',
|
||||
pending: 'pending',
|
||||
granted: 'granted',
|
||||
denied: 'denied',
|
||||
disallowedForThisSite: 'disallowed_for_this_site',
|
||||
};
|
||||
|
||||
export const DECODED_ROUTES = {
|
||||
COURSE_UNIT: [
|
||||
'/container/:blockId/:sequenceId',
|
||||
'/container/:blockId',
|
||||
],
|
||||
};
|
||||
@@ -1,223 +0,0 @@
|
||||
// @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;
|
||||
@@ -1,29 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,214 +0,0 @@
|
||||
// @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;
|
||||
@@ -1,119 +0,0 @@
|
||||
// @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;
|
||||
@@ -1,190 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
// @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;
|
||||
@@ -1,21 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
// @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;
|
||||
@@ -1,57 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
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;
|
||||
@@ -1,5 +0,0 @@
|
||||
.tag-bubble.pgn__chip {
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
background-color: transparent;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
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;
|
||||
@@ -1,63 +0,0 @@
|
||||
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: '',
|
||||
},
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
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',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as taxonomyTagsMock } from './taxonomyTagsMock';
|
||||
export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock';
|
||||
export { default as contentDataMock } from './contentDataMock';
|
||||
export { default as updateContentTaxonomyTagsMock } from './updateContentTaxonomyTagsMock';
|
||||
@@ -1,46 +0,0 @@
|
||||
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',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
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',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
// @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]);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// @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]);
|
||||
});
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
// @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] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,175 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
// @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
|
||||
*/
|
||||
@@ -1,2 +0,0 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as ContentTagsDrawer } from './ContentTagsDrawer';
|
||||
@@ -1,38 +0,0 @@
|
||||
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;
|
||||
@@ -1,2 +0,0 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const extractOrgFromContentId = (contentId) => contentId.split('+')[0].split(':')[1];
|
||||
@@ -1,511 +0,0 @@
|
||||
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;
|
||||
@@ -1,13 +0,0 @@
|
||||
@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";
|
||||
@@ -1,1898 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -1,3063 +0,0 @@
|
||||
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,
|
||||
};
|
||||