Compare commits

..

4 Commits

Author SHA1 Message Date
Ihor Romaniuk
80f93fe5b1 feat: replace hardcoded edx string in page head (#424)
* feat: replace hardcoded edx string in page head with site_name from configs

* feat: add ability to obtain site name dynamically and fix tests
2023-03-09 13:51:14 -05:00
Ihor Romaniuk
68b71f2c33 feat: replace hardcoded logo with logo from configs (#423) 2023-01-24 13:23:55 -05:00
Adolfo R. Brandes
8b656b5895 fix: Editors should support runtime configuration
Fetching settings directly via `process.env` circumvents the runtime
configuration mechanism.  Change the editor page to use `getConfig()`
instead.
2022-12-15 12:36:15 +00:00
Ghassan Maslamani
a49bff03dc fix: force studio url to reload if changed
This chagne make it possible if this module was loaded **then**
  the configuration for studio url is changed, then it will pick
  the last value.

  More context overhangio/tutor-mfe/issues/86
2022-12-12 09:00:06 +00:00
945 changed files with 34256 additions and 97890 deletions

13
.env
View File

@@ -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=''

View File

@@ -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'

View File

@@ -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"

View File

@@ -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',
},
},
);
});

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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
View File

@@ -20,6 +20,3 @@ temp/babel-plugin-react-intl
/temp
/.vscode
/module.config.js
# Local environment overrides
.env.private

2
.nvmrc
View File

@@ -1 +1 @@
18
v16

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -8,6 +8,3 @@ coverage:
default:
target: auto
threshold: 0%
ignore:
- "src/grading-settings/grading-scale/react-ranger.js"
- "src/index.js"

View File

@@ -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.

View File

@@ -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
~~~~~
Youll 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``.

View 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. />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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>

View File

@@ -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
}
]
}

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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,
};

View File

@@ -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,
}),
);
});
});

View File

@@ -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);

View File

@@ -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();
});
});
});

View File

@@ -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,
},
};

View File

@@ -1,2 +0,0 @@
// eslint-disable-next-line import/prefer-default-export
export { default as advancedSettingsMock } from './advancedSettings';

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
};
}

View File

@@ -1,2 +0,0 @@
/* eslint-disable import/prefer-default-export */
export { default as AdvancedSettings } from './AdvancedSettings';

View File

@@ -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;

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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');
});
});

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -1 +0,0 @@
$text-color-base: $gray-700;

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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 youve 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;

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -1,9 +0,0 @@
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -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;
}
}

View File

@@ -1,11 +0,0 @@
.text-black {
color: $black;
}
.h-200px {
height: 200px;
}
.mw-300px {
max-width: 300px;
}

View File

@@ -1,2 +0,0 @@
$text-color-base: $gray-700;
$text-color-weak: #3E3E3C;

View File

@@ -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>&nbsp;</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',
],
};

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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();
});
});
});

View File

@@ -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;

View File

@@ -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();
});
});
});

View File

@@ -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;

View File

@@ -1,5 +0,0 @@
.tag-bubble.pgn__chip {
border-style: solid;
border-width: 2px;
background-color: transparent;
}

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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: '',
},
};

View File

@@ -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',
],
},
],
},
],
},
};

View File

@@ -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';

View File

@@ -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',
},
],
};

View File

@@ -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',
],
},
],
},
],
},
};

View File

@@ -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]);
}

View File

@@ -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]);
});
});

View File

@@ -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] });
},
});
};

View File

@@ -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);
});
});
});

View File

@@ -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
*/

View File

@@ -1,2 +0,0 @@
// eslint-disable-next-line import/prefer-default-export
export { default as ContentTagsDrawer } from './ContentTagsDrawer';

View File

@@ -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;

View File

@@ -1,2 +0,0 @@
// eslint-disable-next-line import/prefer-default-export
export const extractOrgFromContentId = (contentId) => contentId.split('+')[0].split(':')[1];

View File

@@ -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;

View File

@@ -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";

View File

@@ -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);
});
});

View File

@@ -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,
},
},
};

View File

@@ -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,
},
};

View File

@@ -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,
};

Some files were not shown because too many files have changed in this diff Show More