Compare commits

..

9 Commits

Author SHA1 Message Date
Kristin Aoki
d80a68132a feat: bump frontend-lib-content-components to 2.5.1 (#1174)
The primary purpose of this version bump backport is to remove the 2U feedback form
link from the editors (https://github.com/openedx/frontend-lib-content-components/issues/476),
but several other improvements will also be pulled in:

* Fix the Text editor so that when an image is added, it is added at the cursor,
  instead of the beginning of the component.
* Improve editor load time by reducing API calls and switching some calls to be lazy.
* Align controls better in the group feedback component.
* Add validation for start & stop date fields.
* Fix image handling bugs in both the raw & visual text editors.

Full changelog: https://github.com/openedx/frontend-lib-content-components/compare/v2.1.11...v2.5.1

Co-authored-by: Kyle McCormick <kyle@axim.org>
2024-07-23 10:44:07 -04:00
Maria Grimaldi
b66238c7c0 fix: bump frontend-lib-content-components package (#1071) (#1075)
Co-authored-by: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com>
2024-06-06 13:39:44 -04:00
Chris Chávez
e4c5238f70 fix: Bug - Unusable "Languages" taxonomy appears in tagging drawer (#1057)
* Hide language taxonomy when empty
* New message on search result when taxonomy is empty
* Empty taxonomies message added in drawer
2024-06-05 17:30:17 +05:30
Yusuf Musleh
1bc759a1e7 fix: Search result redirect to unit lib component (#1027) (#1069)
This change fixes redirection to the library component in the unit when selecting the search result. It also fixes an issue with navigating to the library MFE when selecting a library component.
2024-06-05 17:19:15 +05:30
Ihor Romaniuk
5cc04f8a80 fix: info icon shrinking on advanced settings page (#1068) 2024-06-03 11:20:35 -04:00
Chris Chávez
de4189b4a5 feat: Show toast when exporting course tags (#1051) 2024-06-03 20:02:53 +05:30
Maria Grimaldi
785b91d3c7 fix: allow grace period minutes only (#1064) (#1067)
* fix: allow grace period minutes only

* fix: zero minutes error

Co-authored-by: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com>
2024-06-03 09:31:26 -04:00
Glib Glugovskiy
a63409eaa6 fix: wrong lock status update message (#1053) (#1054)
Co-authored-by: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com>
2024-05-29 09:46:51 -04:00
Glib Glugovskiy
3c8e5b2501 fix: update date using utc timezone instead of local (#1043) (#1055)
* fix: update date using utc timezone instead of local

* fix: lint error

Co-authored-by: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com>
2024-05-29 09:46:28 -04:00
937 changed files with 14816 additions and 84085 deletions

6
.env
View File

@@ -1,4 +1,3 @@
APP_ID='authoring'
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=''
BASE_URL=''
@@ -35,13 +34,12 @@ ENABLE_UNIT_PAGE=false
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true
ENABLE_CERTIFICATE_PAGE=true
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=false
INVITE_STUDENTS_EMAIL_TO=''
ENABLE_HOME_PAGE_COURSE_API_V2=true
AI_TRANSLATIONS_BASE_URL=''
ENABLE_HOME_PAGE_COURSE_API_V2=false
ENABLE_CHECKLIST_QUALITY=''
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
LIBRARY_SUPPORTED_BLOCKS="problem,video,html"

View File

@@ -1,4 +1,3 @@
APP_ID='authoring'
NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2001'
@@ -36,7 +35,6 @@ ENABLE_TEAM_TYPE_SETTING=false
ENABLE_UNIT_PAGE=false
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
@@ -44,7 +42,7 @@ HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=true
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2=true
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'
ENABLE_HOME_PAGE_COURSE_API_V2=false
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
LIBRARY_SUPPORTED_BLOCKS="problem,video,html"

View File

@@ -1,4 +1,3 @@
APP_ID='authoring'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2001'
CREDENTIALS_BASE_URL='http://localhost:18150'
@@ -32,11 +31,9 @@ ENABLE_TEAM_TYPE_SETTING=false
ENABLE_UNIT_PAGE=true
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
LIBRARY_SUPPORTED_BLOCKS="problem,video,html"

View File

@@ -11,9 +11,8 @@ module.exports = createConfig(
}],
'template-curly-spacing': 'off',
'react-hooks/exhaustive-deps': 'off',
indent: ['error', 2],
'no-restricted-exports': 'off',
// There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers
'no-restricted-syntax': 'off',
},
settings: {
// Import URLs should be resolved using aliases

View File

@@ -1,7 +0,0 @@
version: 2
updates:
# Adding new check for github-actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -9,34 +9,14 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
node-version: ${{ env.NODE_VER }}
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v4
with:
name: code-coverage-report-${{ matrix.node }}
# When we're only using Node 20, replace the line above with the following:
# name: code-coverage-report
path: coverage/*.*
coverage:
runs-on: ubuntu-latest
needs: tests
steps:
- uses: actions/checkout@v4
- name: Download code coverage results
uses: actions/download-artifact@v4
with:
name: code-coverage-report-20
# When we're only using Node 20, replace the line above with the following:
# name: code-coverage-report
- name: Upload coverage
uses: codecov/codecov-action@v4
with:

2
.nvmrc
View File

@@ -1 +1 @@
20
18

View File

@@ -26,7 +26,6 @@
"scss/at-rule-no-unknown": true,
"scss/at-import-partial-extension": null,
"scss/comment-no-empty": null,
"import-notation": "string",
"property-no-unknown": [true, {
"ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"]
}],

View File

@@ -1,2 +1,2 @@
# The following users are the maintainers of all frontend-app-authoring files
# The following users are the maintainers of all frontend-app-course-authoring files
* @openedx/2u-tnl

View File

@@ -35,12 +35,13 @@ pull_translations:
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-platform paragon frontend-component-footer frontend-app-course-authoring
$(intl_imports) frontend-component-ai-translations frontend-lib-content-components frontend-platform paragon frontend-component-footer frontend-app-course-authoring
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:
@@ -53,7 +54,7 @@ validate:
npm run i18n_extract
npm run lint -- --max-warnings 0
npm run types
npm run test:ci
npm run test
npm run build
.PHONY: validate.ci

View File

@@ -1,5 +1,5 @@
frontend-app-authoring
######################
frontend-app-course-authoring
#############################
|license-badge| |status-badge| |codecov-badge|
@@ -7,9 +7,9 @@ frontend-app-authoring
Purpose
*******
This implements most of the frontend for **Open edX Studio**, allowing authors to create and edit courses, libraries, and their learning components.
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
A few parts of Studio still default to the `"legacy" pages defined in edx-platform <https://github.com/openedx/edx-platform/tree/master/cms>`_, but those are rapidly being deprecated and replaced with the React- and Paragon-based pages defined here.
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
@@ -18,87 +18,51 @@ Getting Started
Prerequisites
=============
`Tutor`_ is currently recommended as a development environment for the Authoring
MFE. Most likely, it already has this MFE configured; however, you'll need to
make some changes in order to run it in development mode. You can refer
to the `relevant tutor-mfe documentation`_ for details, or follow the quick
guide below.
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
=============
Cloning and Setup
=================
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.
1. Clone your new repo:
Cloning and Startup
===================
.. code-block:: bash
git clone https://github.com/openedx/frontend-app-authoring.git
1. Clone the repo:
2. Use node v20.x.
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
The current version of the micro-frontend build scripts supports node 20.
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 <https://github.com/nvm-sh/nvm>`_.
2. Use node v18.x.
3. Stop the Tutor devstack, if it's running: ``tutor dev stop``
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`_.
4. Next, we need to tell Tutor that we're going to be running this repo in
development mode, and it should be excluded from the ``mfe`` container that
otherwise runs every MFE. Run this:
3. Install npm dependencies:
.. code-block:: bash
``cd frontend-app-course-authoring && npm install``
tutor mounts add /path/to/frontend-app-authoring
5. Start Tutor in development mode. This command will start the LMS and Studio,
and other required MFEs like ``authn`` and ``account``, but will not start
the Authoring MFE, which we're going to run on the host instead of in a
container managed by Tutor. Run:
4. Start the dev server:
.. code-block:: bash
``npm start``
tutor dev start lms cms mfe
Startup
=======
1. Install npm dependencies:
.. code-block:: bash
cd frontend-app-authoring && npm ci
2. Start the dev server:
.. code-block:: bash
npm run dev
Then you can access the app at http://apps.local.openedx.io:2001/course-authoring/home
Troubleshooting
---------------
* If you see an "Invalid Host header" error, then you're probably using a different domain name for your devstack such as
``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run
these commands to update your devstack's domain names:
.. code-block:: bash
tutor dev stop
tutor config save --set LMS_HOST=local.openedx.io --set CMS_HOST=studio.local.openedx.io
tutor dev launch -I --skip-build
tutor dev stop authoring # We will run this MFE on the host
* If tutor-mfe is not starting the authoring MFE in development mode (eg. `tutor dev start authoring` fails), it may be due to
using a tutor version that expects the MFE name to be frontend-app-course-authoring (the previous name of this repo). To fix
this, you can rename the cloned repo directory to frontend-app-course-authoring. More information can be found in
[this forum post](https://discuss.openedx.org/t/repo-rename-frontend-app-course-authoring-frontend-app-authoring/13930/2)
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
or whatever port you setup.
Features
@@ -181,6 +145,10 @@ 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
==================================
@@ -296,22 +264,6 @@ In additional to the standard settings, the following local configuration items
Tagging/Taxonomy functionality.
Feature: Libraries V2/Legacy Tabs
=================================
Configuration
-------------
In additional to the standard settings, the following local configurations can be set to switch between different library modes:
* ``MEILISEARCH_ENABLED``: Studio setting which is enabled when the `meilisearch plugin`_ is installed.
* ``edx-platform`` Waffle flags:
* ``contentstore.new_studio_mfe.disable_legacy_libraries``: this feature flag must be OFF to show legacy Libraries V1
* ``contentstore.new_studio_mfe.disable_new_libraries``: this feature flag must be OFF to show Content Libraries V2
.. _meilisearch plugin: https://github.com/open-craft/tutor-contrib-meilisearch
Developing
**********
@@ -344,8 +296,8 @@ The production build is created with ``npm run build``.
:target: https://travis-ci.com/edx/frontend-app-course-authoring
.. |Codecov| image:: https://codecov.io/gh/edx/frontend-app-course-authoring/branch/master/graph/badge.svg
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-authoring.svg
:target: @edx/frontend-app-authoring
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
:target: @edx/frontend-app-course-authoring
Internationalization
====================

View File

@@ -4,11 +4,11 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: 'frontend-app-authoring'
description: "The frontend (MFE) for Open edX Authoring (aka Studio)"
name: 'frontend-app-course-authoring'
description: "The frontend (MFE) for Open edX Course Authoring (aka Studio)"
links:
- url: "https://github.com/openedx/frontend-app-authoring"
title: "Frontend app authoring"
- url: "https://github.com/openedx/frontend-app-course-authoring"
title: "Frontend app course authoring"
icon: "Web"
annotations:
openedx.org/arch-interest-groups: ""

16103
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
{
"name": "@edx/frontend-app-authoring",
"name": "@edx/frontend-app-course-authoring",
"version": "0.1.0",
"description": "Frontend application template",
"repository": {
"type": "git",
"url": "git+https://github.com/openedx/frontend-app-authoring.git"
"url": "git+https://github.com/openedx/frontend-app-course-authoring.git"
},
"browserslist": [
"extends @edx/browserslist-config"
@@ -13,14 +13,12 @@
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract",
"stylelint": "stylelint \"plugins/**/*.scss\" \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
"lint:fix": "npm run stylelint -- --fix && fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix",
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"start:with-theme": "paragon install-theme && npm start && npm install",
"dev": "PUBLIC_PATH=/authoring/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
"test:ci": "TZ=UTC fedx-scripts jest --silent --coverage --passWithNoTests",
"types": "tsc --noEmit"
},
"husky": {
@@ -30,30 +28,32 @@
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/openedx/frontend-app-authoring#readme",
"homepage": "https://github.com/openedx/frontend-app-course-authoring#readme",
"publishConfig": {
"access": "public"
},
"bugs": {
"url": "https://github.com/openedx/frontend-app-authoring/issues"
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
},
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
"@codemirror/lint": "^6.2.1",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-component-footer": "^14.1.0",
"@edx/frontend-component-header": "^5.6.0",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-ai-translations": "^2.0.0",
"@edx/frontend-component-footer": "^13.0.2",
"@edx/frontend-component-header": "^5.1.0",
"@edx/frontend-enterprise-hotjar": "^2.0.0",
"@edx/frontend-platform": "^8.0.3",
"@edx/frontend-lib-content-components": "^2.5.1",
"@edx/frontend-platform": "7.0.1",
"@edx/openedx-atlas": "^0.6.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0",
"@meilisearch/instant-meilisearch": "^0.17.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
@@ -64,67 +64,62 @@
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
"@openedx/frontend-build": "^14.0.14",
"@openedx/frontend-plugin-framework": "^1.2.1",
"@openedx/paragon": "^22.8.1",
"@redux-devtools/extension": "^3.3.0",
"@openedx/frontend-plugin-framework": "^1.1.0",
"@openedx/paragon": "^22.2.1",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "4.36.1",
"@tinymce/tinymce-react": "^3.14.0",
"classnames": "2.5.1",
"codemirror": "^6.0.0",
"classnames": "2.2.6",
"core-js": "3.8.1",
"email-validator": "2.0.4",
"fast-xml-parser": "^4.0.10",
"file-saver": "^2.0.5",
"formik": "2.4.6",
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
"formik": "2.2.6",
"jszip": "^3.10.1",
"lodash": "4.17.21",
"meilisearch": "^0.41.0",
"moment": "2.30.1",
"moment-shortformat": "^2.1.0",
"npm": "^10.8.1",
"meilisearch": "^0.38.0",
"moment": "2.29.4",
"prop-types": "^15.8.1",
"react": "17.0.2",
"react-datepicker": "^4.13.0",
"react-dom": "17.0.2",
"react-error-boundary": "^4.0.13",
"react-helmet": "^6.1.0",
"react-onclickoutside": "^6.13.0",
"react-redux": "7.2.9",
"react-responsive": "9.0.2",
"react-router": "6.27.0",
"react-router-dom": "6.27.0",
"react-router": "6.16.0",
"react-router-dom": "6.16.0",
"react-select": "5.8.0",
"react-textarea-autosize": "^8.5.3",
"react-textarea-autosize": "^8.4.1",
"react-transition-group": "4.4.5",
"redux": "4.0.5",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.1",
"reselect": "^4.1.5",
"start": "^5.1.0",
"tinymce": "^5.10.4",
"regenerator-runtime": "0.13.7",
"universal-cookie": "^4.0.4",
"uuid": "^3.4.0",
"xmlchecker": "^0.1.0",
"yup": "0.31.1"
},
"devDependencies": {
"@edx/react-unit-test-utils": "3.0.0",
"@edx/stylelint-config-edx": "2.3.3",
"@edx/browserslist-config": "1.2.0",
"@edx/react-unit-test-utils": "^2.0.0",
"@edx/reactifex": "^1.0.3",
"@edx/stylelint-config-edx": "2.3.0",
"@edx/typescript-config": "^1.0.1",
"@openedx/frontend-build": "13.1.0",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^13.2.1",
"@types/lodash": "^4.17.7",
"axios": "^0.28.0",
"axios-mock-adapter": "1.22.0",
"eslint-import-resolver-webpack": "^0.13.8",
"fetch-mock-jest": "^1.5.1",
"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",
"redux-mock-store": "^1.5.4"
"reactifex": "1.1.1",
"ts-loader": "^9.5.0"
},
"peerDependencies": {
"decode-uri-component": ">=0.2.2"
}
}

View File

@@ -3,14 +3,14 @@
"version": "0.1.0",
"description": "Calculator configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -3,14 +3,14 @@
"version": "0.1.0",
"description": "edxnotes configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "Learning Assistant configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
@@ -11,7 +11,7 @@
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -3,10 +3,10 @@ import { useDispatch, useSelector } from 'react-redux';
import { camelCase } from 'lodash';
import { Icon } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { SelectableBox } from '@edx/frontend-lib-content-components';
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { useNavigate } from 'react-router-dom';
import SelectableBox from 'CourseAuthoring/editors/sharedComponents/SelectableBox';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import { useModel } from 'CourseAuthoring/generic/model-store';
import Loading from 'CourseAuthoring/generic/Loading';

View File

@@ -3,7 +3,8 @@
"version": "0.1.0",
"description": "Live course configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-lib-content-components": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"@reduxjs/toolkit": "*",
@@ -15,7 +16,7 @@
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -1,176 +1,69 @@
import { useEffect, useState, useRef } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
} from '@openedx/paragon';
import { Info } from '@openedx/paragon/icons';
import { updateModel, useModel } from 'CourseAuthoring/generic/model-store';
import { Hyperlink } from '@openedx/paragon';
import { useModel } from 'CourseAuthoring/generic/model-store';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import Loading from 'CourseAuthoring/generic/Loading';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
import { useAppSetting, useIsMobile } from 'CourseAuthoring/utils';
import { getLoadingStatus, getSavingStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
import { updateSavingStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const ORASettings = ({ onClose }) => {
const dispatch = useDispatch();
const { formatMessage } = useIntl();
const alertRef = useRef(null);
const updateSettingsRequestStatus = useSelector(getSavingStatus);
const loadingStatus = useSelector(getLoadingStatus);
const isMobile = useIsMobile();
const modalVariant = isMobile ? 'dark' : 'default';
const ORASettings = ({ intl, onClose }) => {
const appId = 'ora_settings';
const appInfo = useModel('courseApps', appId);
const [enableFlexiblePeerGrade, saveSetting] = useAppSetting(
'forceOnFlexiblePeerOpenassessments',
);
const initialFormValues = { enableFlexiblePeerGrade };
const [formValues, setFormValues] = useState(initialFormValues);
const [saveError, setSaveError] = useState(false);
const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default';
const handleSettingsSave = (values) => saveSetting(values.enableFlexiblePeerGrade);
const handleSubmit = async (event) => {
let success = true;
event.preventDefault();
success = success && await handleSettingsSave(formValues);
await setSaveError(!success);
if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) {
success = await dispatch(updateModel({
modelType: 'courseApps',
model: {
id: appId, enabled: formValues.enableFlexiblePeerGrade,
},
}));
}
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
};
const handleChange = (e) => {
setFormValues({ enableFlexiblePeerGrade: e.target.checked });
};
useEffect(() => {
if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) {
dispatch(updateSavingStatus({ status: '' }));
onClose();
}
}, [updateSettingsRequestStatus]);
const renderBody = () => {
switch (loadingStatus) {
case RequestStatus.SUCCESSFUL:
return (
<>
{saveError && (
<Alert variant="danger" icon={Info} ref={alertRef}>
<Alert.Heading>
{formatMessage(messages.errorSavingTitle)}
</Alert.Heading>
{formatMessage(messages.errorSavingMessage)}
</Alert>
)}
<FormSwitchGroup
id="enable-flexible-peer-grade"
name="enableFlexiblePeerGrade"
label={(
<div className="d-flex align-items-center">
{formatMessage(messages.enableFlexPeerGradeLabel)}
{formValues.enableFlexiblePeerGrade && (
<Badge className="ml-2" variant="success" data-testid="enable-badge">
{formatMessage(messages.enabledBadgeLabel)}
</Badge>
)}
</div>
)}
helpText={(
<div>
<p>{formatMessage(messages.enableFlexPeerGradeHelp)}</p>
<span className="py-3">
<Hyperlink
className="text-primary-500 small"
destination={appInfo.documentationLinks?.learnMoreConfiguration}
target="_blank"
rel="noreferrer noopener"
>
{formatMessage(messages.ORASettingsHelpLink)}
</Hyperlink>
</span>
</div>
)}
onChange={handleChange}
checked={formValues.enableFlexiblePeerGrade}
/>
</>
);
case RequestStatus.DENIED:
return <PermissionDeniedAlert />;
case RequestStatus.FAILED:
return <ConnectionErrorAlert />;
default:
return <Loading />;
}
};
const title = (
<div>
<p>{intl.formatMessage(messages.heading)}</p>
<div className="pt-3">
<Hyperlink
className="text-primary-500 small"
destination={appInfo.documentationLinks?.learnMoreConfiguration}
target="_blank"
rel="noreferrer noopener"
>
{intl.formatMessage(messages.ORASettingsHelpLink)}
</Hyperlink>
</div>
</div>
);
return (
<ModalDialog
title={formatMessage(messages.heading)}
isOpen
<AppSettingsModal
appId={appId}
title={title}
onClose={onClose}
size="lg"
variant={modalVariant}
hasCloseButton={isMobile}
isFullscreenScroll
isFullscreenOnMobile
initialValues={{ enableFlexiblePeerGrade }}
validationSchema={{ enableFlexiblePeerGrade: Yup.boolean() }}
onSettingsSave={handleSettingsSave}
hideAppToggle
>
<Form onSubmit={handleSubmit} data-testid="proctoringForm">
<ModalDialog.Header>
<ModalDialog.Title>
{formatMessage(messages.heading)}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{renderBody()}
</ModalDialog.Body>
<ModalDialog.Footer className="p-4">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{formatMessage(messages.cancelLabel)}
</ModalDialog.CloseButton>
<StatefulButton
labels={{
default: formatMessage(messages.saveLabel),
pending: formatMessage(messages.pendingSaveLabel),
}}
description="Form save button"
data-testid="submissionButton"
disabled={submitButtonState === RequestStatus.IN_PROGRESS}
state={submitButtonState}
type="submit"
/>
</ActionRow>
</ModalDialog.Footer>
</Form>
</ModalDialog>
{({ values, handleChange, handleBlur }) => (
<FormSwitchGroup
id="enable-flexible-peer-grade"
name="enableFlexiblePeerGrade"
label={intl.formatMessage(messages.enableFlexPeerGradeLabel)}
helpText={intl.formatMessage(messages.enableFlexPeerGradeHelp)}
onChange={handleChange}
onBlur={handleBlur}
checked={values.enableFlexiblePeerGrade}
/>
)}
</AppSettingsModal>
);
};
ORASettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default ORASettings;
export default injectIntl(ORASettings);

View File

@@ -1,152 +1,33 @@
import {
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import ReactDOM from 'react-dom';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { getCourseAppsApiUrl, getCourseAdvancedSettingsApiUrl } from 'CourseAuthoring/pages-and-resources/data/api';
import { fetchCourseApps, fetchCourseAppSettings } from 'CourseAuthoring/pages-and-resources/data/thunks';
import { shallow } from '@edx/react-unit-test-utils';
import ORASettings from './Settings';
import messages from './messages';
import {
courseId,
inititalState,
} from './factories/mockData';
let axiosMock;
let store;
const oraSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'), // use actual for all non-hook parts
injectIntl: (component) => component,
intlShape: {},
}));
jest.mock('yup', () => ({
boolean: jest.fn().mockReturnValue('Yub.boolean'),
}));
jest.mock('CourseAuthoring/generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({ documentationLinks: { learnMoreConfiguration: 'https://learnmore.test' } }),
}));
jest.mock('CourseAuthoring/generic/FormSwitchGroup', () => 'FormSwitchGroup');
jest.mock('CourseAuthoring/utils', () => ({
useAppSetting: jest.fn().mockReturnValue(['abitrary value', jest.fn().mockName('saveSetting')]),
}));
jest.mock('CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal', () => 'AppSettingsModal');
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => (
render(
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[oraSettingsUrl]}>
<Routes>
<Route path={oraSettingsUrl} element={<PageWrap><ORASettings onClose={jest.fn()} /></PageWrap>} />
</Routes>
</MemoryRouter>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
)
);
const mockStore = async ({
apiStatus,
enabled,
}) => {
const settings = ['forceOnFlexiblePeerOpenassessments'];
const fetchCourseAppsUrl = `${getCourseAppsApiUrl()}/${courseId}`;
const fetchAdvancedSettingsUrl = `${getCourseAdvancedSettingsApiUrl()}/${courseId}`;
axiosMock.onGet(fetchCourseAppsUrl).reply(
200,
[{
allowed_operations: { enable: false, configure: true },
description: 'setting',
documentation_links: { learnMoreConfiguration: '' },
enabled,
id: 'ora_settings',
name: 'Flexible Peer Grading for ORAs',
}],
);
axiosMock.onGet(fetchAdvancedSettingsUrl).reply(
apiStatus,
{ force_on_flexible_peer_openassessments: { value: enabled } },
);
await executeThunk(fetchCourseApps(courseId), store.dispatch);
await executeThunk(fetchCourseAppSettings(courseId, settings), store.dispatch);
const props = {
onClose: jest.fn().mockName('onClose'),
intl: {
formatMessage: (message) => message.defaultMessage,
},
};
describe('ORASettings', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(inititalState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('Flexible peer grading configuration modal is visible', async () => {
renderComponent();
expect(screen.getByRole('dialog')).toBeVisible();
});
it('Displays "Configure Flexible Peer Grading" heading', async () => {
renderComponent();
const headingElement = screen.getByText(messages.heading.defaultMessage);
expect(headingElement).toBeVisible();
});
it('Displays loading component', () => {
renderComponent();
const loadingElement = screen.getByRole('status');
expect(within(loadingElement).getByText('Loading...')).toBeInTheDocument();
});
it('Displays Connection Error Alert', async () => {
await mockStore({ apiStatus: 404, enabled: true });
renderComponent();
const errorAlert = screen.getByRole('alert');
expect(within(errorAlert).getByText('We encountered a technical error when loading this page.', { exact: false })).toBeVisible();
});
it('Displays Permissions Error Alert', async () => {
await mockStore({ apiStatus: 403, enabled: true });
renderComponent();
const errorAlert = screen.getByRole('alert');
expect(within(errorAlert).getByText('You are not authorized to view this page', { exact: false })).toBeVisible();
});
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
renderComponent();
await mockStore({ apiStatus: 200, enabled: true });
waitFor(() => {
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
const enableBadge = screen.getByTestId('enable-badge');
expect(label).toBeVisible();
expect(enableBadge).toHaveTextContent('Enabled');
});
});
it('Displays title, helper text and hides badge when flexible peer grading button is disabled', async () => {
renderComponent();
await mockStore({ apiStatus: 200, enabled: false });
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
const enableBadge = screen.queryByTestId('enable-badge');
expect(label).toBeVisible();
expect(enableBadge).toBeNull();
it('should render', () => {
const wrapper = shallow(<ORASettings {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ORASettings should render 1`] = `
<AppSettingsModal
appId="ora_settings"
hideAppToggle={true}
initialValues={
Object {
"enableFlexiblePeerGrade": "abitrary value",
}
}
onClose={[MockFunction onClose]}
onSettingsSave={[Function]}
title={
<div>
<p>
Configure open response assessment
</p>
<div
className="pt-3"
>
<withDeprecatedProps(Hyperlink)
className="text-primary-500 small"
destination="https://learnmore.test"
rel="noreferrer noopener"
target="_blank"
>
Learn more about open response assessment settings
</withDeprecatedProps(Hyperlink)>
</div>
</div>
}
validationSchema={
Object {
"enableFlexiblePeerGrade": "Yub.boolean",
}
}
>
[Function]
</AppSettingsModal>
`;

View File

@@ -1,32 +0,0 @@
export const courseId = 'course-v1:org+num+run';
export const inititalState = {
courseDetail: {
courseId,
status: 'successful',
},
pagesAndResources: {
courseAppIds: ['ora_settings'],
loadingStatus: 'in-progress',
savingStatus: '',
courseAppsApiStatus: {},
courseAppSettings: {},
},
models: {
courseApps: {
ora_settings: {
id: 'ora_settings',
name: 'Flexible Peer Grading',
enabled: true,
description: 'Enable flexible peer grading',
allowedOperations: {
enable: false,
configure: true,
},
documentationLinks: {
learnMoreConfiguration: '',
},
},
},
},
};

View File

@@ -3,51 +3,19 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.ora.heading',
defaultMessage: 'Configure Flexible Peer Grading',
description: 'Title for the modal dialog header',
defaultMessage: 'Configure open response assessment',
},
ORASettingsHelpLink: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.link',
defaultMessage: 'Learn more about open response assessment settings',
description: 'Descriptive text for the hyperlink to the docs site',
},
enableFlexPeerGradeLabel: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.label',
defaultMessage: 'Flex Peer Grading',
description: 'Label for form switch',
},
enableFlexPeerGradeHelp: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.help',
defaultMessage: 'Turn on Flexible Peer Grading for all open response assessments in the course with peer grading.',
description: 'Help text describing what happens when the switch is enabled',
},
enabledBadgeLabel: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.enabled-badge.label',
defaultMessage: 'Enabled',
description: 'Label for badge that show users that a setting is enabled',
},
cancelLabel: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.cancel-button.label',
defaultMessage: 'Cancel',
description: 'Label for button that cancels user changes',
},
saveLabel: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-button.label',
defaultMessage: 'Save',
description: 'Label for button that saves user changes',
},
pendingSaveLabel: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.pending-save-button.label',
defaultMessage: 'Saving',
description: 'Label for button that has pending api save calls',
},
errorSavingTitle: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.title',
defaultMessage: 'We couldn\'t apply your changes.',
},
errorSavingMessage: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.message',
defaultMessage: 'Please check your entries and try again.',
},
});

View File

@@ -3,16 +3,15 @@
"version": "0.1.0",
"description": "Open Response Assessment configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"react-redux": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -64,8 +64,6 @@ const ProctoringSettings = ({ intl, onClose }) => {
}
const { courseId } = useContext(PagesAndResourcesContext);
const courseDetails = useModel('courseDetails', courseId);
const org = courseDetails?.org;
const appInfo = useModel('courseApps', 'proctoring');
const alertRef = React.createRef();
const saveStatusAlertRef = React.createRef();
@@ -148,9 +146,9 @@ const ProctoringSettings = ({ intl, onClose }) => {
setSaveSuccess(true);
setSaveError(false);
setSubmissionInProgress(false);
}).catch((error) => {
}).catch(() => {
setSaveSuccess(false);
setSaveError(error);
setSaveError(true);
setSubmissionInProgress(false);
});
}
@@ -460,44 +458,6 @@ const ProctoringSettings = ({ intl, onClose }) => {
}
function renderSaveError() {
let errorMessage = (
<FormattedMessage
id="authoring.proctoring.alert.error"
defaultMessage={`
We encountered a technical error while trying to save proctored exam settings.
This might be a temporary issue, so please try again in a few minutes.
If the problem persists, please go to the {support_link} for help.
`}
values={{
support_link: (
<Alert.Link href={getConfig().SUPPORT_URL}>
{intl.formatMessage(messages['authoring.proctoring.support.text'])}
</Alert.Link>
),
}}
/>
);
if (saveError?.response.status === 403) {
errorMessage = (
<FormattedMessage
id="authoring.proctoring.alert.error.forbidden"
defaultMessage={`
You do not have permission to edit proctored exam settings for this course.
If you are a course team member and this problem persists,
please go to the {support_link} for help.
`}
values={{
support_link: (
<Alert.Link href={getConfig().SUPPORT_URL}>
{intl.formatMessage(messages['authoring.proctoring.support.text'])}
</Alert.Link>
),
}}
/>
);
}
return (
<Alert
variant="danger"
@@ -507,7 +467,21 @@ const ProctoringSettings = ({ intl, onClose }) => {
onClose={() => setSaveError(false)}
dismissible
>
{errorMessage}
<FormattedMessage
id="authoring.examsettings.alert.error"
defaultMessage={`
We encountered a technical error while trying to save proctored exam settings.
This might be a temporary issue, so please try again in a few minutes.
If the problem persists, please go to the {support_link} for help.
`}
values={{
support_link: (
<Alert.Link href={getConfig().SUPPORT_URL}>
{intl.formatMessage(messages['authoring.proctoring.support.text'])}
</Alert.Link>
),
}}
/>
</Alert>
);
}
@@ -516,7 +490,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
Promise.all([
StudioApiService.getProctoredExamSettingsData(courseId),
ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(),
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders(org) : Promise.resolve(),
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders() : Promise.resolve(),
])
.then(
([settingsResponse, examConfigResponse, ltiProvidersResponse]) => {

View File

@@ -15,9 +15,8 @@ import initializeStore from 'CourseAuthoring/store';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import ProctoredExamSettings from './Settings';
const courseId = 'course-v1%3AedX%2BDemoX%2BDemo_Course';
const defaultProps = {
courseId,
courseId: 'course-v1%3AedX%2BDemoX%2BDemo_Course',
onClose: () => {},
};
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
@@ -35,7 +34,7 @@ const intlWrapper = children => (
let axiosMock;
describe('ProctoredExamSettings', () => {
function setupApp(isAdmin = true, org = undefined) {
function setupApp(isAdmin = true) {
mergeConfig({
EXAMS_BASE_URL: 'http://exams.testing.co',
}, 'CourseAuthoringConfig');
@@ -53,18 +52,12 @@ describe('ProctoredExamSettings', () => {
courseApps: {
proctoring: {},
},
courseDetails: {
[courseId]: {
start: Date(),
},
},
...(org ? { courseDetails: { [courseId]: { org } } } : {}),
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers${org ? `?org=${org}` : ''}`,
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
).reply(200, [
{
name: 'test_lti',
@@ -110,7 +103,9 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
});
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsNo');
expect(zendeskTicketInput.checked).toEqual(true);
});
@@ -120,7 +115,9 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'software_secure' } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'software_secure' } });
});
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
expect(zendeskTicketInput.checked).toEqual(true);
});
@@ -130,7 +127,9 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'mockproc' } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'mockproc' } });
});
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
expect(zendeskTicketInput.checked).toEqual(true);
});
@@ -177,7 +176,9 @@ describe('ProctoredExamSettings', () => {
let enabledProctoredExamCheck = screen.getAllByLabelText('Proctored exams', { exact: false })[0];
expect(enabledProctoredExamCheck.checked).toEqual(true);
fireEvent.click(enabledProctoredExamCheck, { target: { value: false } });
await act(async () => {
fireEvent.click(enabledProctoredExamCheck, { target: { value: false } });
});
enabledProctoredExamCheck = screen.getByLabelText('Proctored exams');
expect(enabledProctoredExamCheck.checked).toEqual(false);
expect(screen.queryByText('Allow opting out of proctored exams')).toBeNull();
@@ -192,7 +193,9 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
});
expect(screen.queryByTestId('allowOptingOutRadio')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
@@ -234,9 +237,13 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
});
const selectButton = screen.getByTestId('submissionButton');
fireEvent.click(selectButton);
await act(async () => {
fireEvent.click(selectButton);
});
// verify alert content and focus management
const escalationEmailError = screen.getByTestId('escalationEmailError');
@@ -245,7 +252,9 @@ describe('ProctoredExamSettings', () => {
// verify alert link links to offending input
const errorLink = screen.getByTestId('escalationEmailErrorLink');
fireEvent.click(errorLink);
await act(async () => {
fireEvent.click(errorLink);
});
const escalationEmailInput = screen.getByTestId('escalationEmail');
expect(document.activeElement).toEqual(escalationEmailInput);
});
@@ -256,12 +265,18 @@ describe('ProctoredExamSettings', () => {
});
const selectElement = screen.getByDisplayValue('proctortrack');
fireEvent.change(selectElement, { target: { value: provider } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: provider } });
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
const proctoringForm = screen.getByTestId('proctoringForm');
fireEvent.submit(proctoringForm);
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
});
const selectButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(selectButton);
});
// verify alert content and focus management
const escalationEmailError = screen.getByTestId('escalationEmailError');
@@ -271,7 +286,9 @@ describe('ProctoredExamSettings', () => {
// verify alert link links to offending input
const errorLink = screen.getByTestId('escalationEmailErrorLink');
fireEvent.click(errorLink);
await act(async () => {
fireEvent.click(errorLink);
});
const escalationEmailInput = screen.getByTestId('escalationEmail');
expect(document.activeElement).toEqual(escalationEmailInput);
});
@@ -281,11 +298,15 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
});
const enableProctoringElement = screen.getByText('Proctored exams');
fireEvent.click(enableProctoringElement);
await act(async () => fireEvent.click(enableProctoringElement));
const selectButton = screen.getByTestId('submissionButton');
fireEvent.click(selectButton);
await act(async () => {
fireEvent.click(selectButton);
});
// verify alert content and focus management
const escalationEmailError = screen.getByTestId('escalationEmailError');
@@ -299,22 +320,24 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
});
const enableProctoringElement = screen.getByText('Proctored exams');
fireEvent.click(enableProctoringElement);
await act(async () => fireEvent.click(enableProctoringElement));
const selectButton = screen.getByTestId('submissionButton');
fireEvent.click(selectButton);
await act(async () => {
fireEvent.click(selectButton);
});
// verify there is no escalation email alert, and focus has been set on save success alert
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => {
@@ -322,20 +345,22 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
});
const selectButton = screen.getByTestId('submissionButton');
fireEvent.click(selectButton);
await act(async () => {
fireEvent.click(selectButton);
});
// verify there is no escalation email alert, and focus has been set on save success alert
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => {
@@ -345,7 +370,9 @@ describe('ProctoredExamSettings', () => {
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
await act(async () => {
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
});
expect(screen.queryByTestId('escalationEmail')).toBeNull();
});
@@ -355,9 +382,13 @@ describe('ProctoredExamSettings', () => {
});
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
await act(async () => {
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
});
expect(screen.queryByTestId('escalationEmail')).toBeNull();
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
await act(async () => {
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
});
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
@@ -368,8 +399,12 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
fireEvent.submit(selectEscalationEmailElement);
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
});
await act(async () => {
fireEvent.submit(selectEscalationEmailElement);
});
// if the error appears, the form has been submitted
expect(screen.getByTestId('escalationEmailError')).toBeDefined();
});
@@ -423,16 +458,6 @@ describe('ProctoredExamSettings', () => {
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Sends the org to the proctoring provider endpoint', async () => {
const isAdmin = false;
const org = 'test-org';
setupApp(isAdmin, org);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Enables all proctoring provider options if user administrator and it is after start date', async () => {
const isAdmin = true;
setupApp(isAdmin);
@@ -603,7 +628,9 @@ describe('ProctoredExamSettings', () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
let submitButton = screen.getByTestId('submissionButton');
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
fireEvent.click(submitButton);
act(() => {
fireEvent.click(submitButton);
});
submitButton = screen.getByTestId('submissionButton');
expect(submitButton).toHaveAttribute('disabled');
@@ -613,13 +640,19 @@ describe('ProctoredExamSettings', () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to proctortrack and set the email
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
});
const escalationEmail = screen.getByTestId('escalationEmail');
expect(escalationEmail.value).toEqual('test@example.com');
fireEvent.change(escalationEmail, { target: { value: 'proctortrack@example.com' } });
await act(async () => {
fireEvent.change(escalationEmail, { target: { value: 'proctortrack@example.com' } });
});
expect(escalationEmail.value).toEqual('proctortrack@example.com');
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
@@ -631,13 +664,11 @@ describe('ProctoredExamSettings', () => {
},
});
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
@@ -647,7 +678,9 @@ describe('ProctoredExamSettings', () => {
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
@@ -658,28 +691,32 @@ describe('ProctoredExamSettings', () => {
},
});
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to test_lti and set the email
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
});
const escalationEmail = screen.getByTestId('escalationEmail');
expect(escalationEmail.value).toEqual('test@example.com');
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
await act(async () => {
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
});
expect(escalationEmail.value).toEqual('test_lti@example.com');
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
// update exam service config
expect(axiosMock.history.patch.length).toBe(1);
@@ -699,19 +736,19 @@ describe('ProctoredExamSettings', () => {
},
});
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
// update exam service config
expect(axiosMock.history.patch.length).toBe(1);
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
@@ -729,13 +766,11 @@ describe('ProctoredExamSettings', () => {
},
});
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Does not update exam service if lti is not enabled in studio', async () => {
@@ -755,7 +790,9 @@ describe('ProctoredExamSettings', () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
// does not update exam service config
expect(axiosMock.history.patch.length).toBe(0);
// does update studio
@@ -769,13 +806,11 @@ describe('ProctoredExamSettings', () => {
},
});
await waitFor(() => {
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Makes studio API call generated error', async () => {
@@ -785,15 +820,15 @@ describe('ProctoredExamSettings', () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
await waitFor(() => {
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Makes exams API call generated error', async () => {
@@ -803,33 +838,15 @@ describe('ProctoredExamSettings', () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
await waitFor(() => {
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
await act(async () => {
fireEvent.click(submitButton);
});
});
test('Exams API permission error', async () => {
axiosMock.onPatch(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(403, 'error');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
await waitFor(() => {
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('You do not have permission to edit proctored exam settings for this course'),
);
expect(document.activeElement).toEqual(errorAlert);
});
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Manages focus correctly after different save statuses', async () => {
@@ -840,30 +857,30 @@ describe('ProctoredExamSettings', () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
expect(axiosMock.history.post.length).toBe(1);
await waitFor(() => {
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
// now make a call that will allow for a successful save
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(2);
await waitFor(() => {
const successAlert = screen.getByTestId('saveSuccess');
expect(successAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(successAlert);
});
const successAlert = screen.getByTestId('saveSuccess');
expect(successAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(successAlert);
});
it('Include Zendesk ticket in post request if user is not an admin', async () => {
@@ -874,9 +891,13 @@ describe('ProctoredExamSettings', () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the proctoring provider
const selectElement = screen.getByDisplayValue('mockproc');
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
});
const submitButton = screen.getByTestId('submissionButton');
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {

View File

@@ -1,16 +1,6 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'authoring.proctoring.alert.error': {
id: 'authoring.proctoring.alert.error',
defaultMessage: 'We encountered a technical error while trying to save proctored exam settings. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {support_link} for help.',
description: 'Alert message for proctoring settings save error.',
},
'authoring.proctoring.alert.forbidden': {
id: 'authoring.proctoring.alert.forbidden',
defaultMessage: 'You do not have permission to edit proctored exam settings for this course. If you are a course team member and this problem persists, please go to the {support_link} for help.',
description: 'Alert message for proctoring settings permission error.',
},
'authoring.proctoring.no': {
id: 'authoring.proctoring.no',
defaultMessage: 'No',

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "Proctoring configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"classnames": "*",
@@ -13,7 +13,7 @@
"moment": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "Progress configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
@@ -11,7 +11,7 @@
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "Teams configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"formik": "*",
@@ -13,7 +13,7 @@
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -26,8 +26,8 @@ const messages = defineMessages({
},
enablePublicWikiHelp: {
id: 'course-authoring.pages-resources.wiki.enable-public-wiki.help',
defaultMessage: `If enabled, any registered user can view the course wiki
even if they are not enrolled in the course`,
defaultMessage: `If enabled, edX users can view the course wiki even when
they're not enrolled in the course.`,
},
});

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "Wiki configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
@@ -11,7 +11,7 @@
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "Xpert Unit Summaries configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-authoring": "*",
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"formik": "*",
@@ -14,7 +14,7 @@
"react-router-dom": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-authoring": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}

View File

@@ -137,12 +137,12 @@ const ResetUnitsButton = ({
const getResetButtonState = () => {
switch (resetStatusRequestStatus) {
case RequestStatus.PENDING:
return 'pending';
case RequestStatus.SUCCESSFUL:
return 'finish';
default:
return 'default';
case RequestStatus.PENDING:
return 'pending';
case RequestStatus.SUCCESSFUL:
return 'finish';
default:
return 'default';
}
};
@@ -246,7 +246,7 @@ const SettingsModal = ({
success = success && await onSettingsSave(values);
}
setSaveError(!success);
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line no-unused-expressions
};
const handleFormikSubmit = ({ handleSubmit, errors }) => async (event) => {

View File

@@ -19,6 +19,15 @@
"matchPackagePatterns": ["@edx", "@openedx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": false
},
{
"matchPackagePatterns": ["@edx/frontend-lib-content-components"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": false,
"schedule": [
"after 1am",
"before 11pm"
]
}
]
}

View File

@@ -11,11 +11,33 @@ import { fetchCourseDetail } from './data/thunks';
import { useModel } from './generic/model-store';
import NotFoundAlert from './generic/NotFoundAlert';
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
import { fetchStudioHomeData } from './studio-home/data/thunks';
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants';
import Loading from './generic/Loading';
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 }) => {
const dispatch = useDispatch();
@@ -23,10 +45,6 @@ const CourseAuthoringPage = ({ courseId, children }) => {
dispatch(fetchCourseDetail(courseId));
}, [courseId]);
useEffect(() => {
dispatch(fetchStudioHomeData());
}, []);
const courseDetail = useModel('courseDetails', courseId);
const courseNumber = courseDetail ? courseDetail.number : null;
@@ -49,18 +67,18 @@ const CourseAuthoringPage = ({ courseId, children }) => {
);
}
return (
<div>
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
{/* While V2 Editors are temporarily served from their own pages
using url pattern containing /editor/,
we shouldn't have the header and footer on these pages.
This functionality will be removed in TNL-9591 */}
{inProgress ? !isEditor && <Loading />
: (!isEditor && (
<Header
number={courseNumber}
org={courseOrg}
title={courseTitle}
contextId={courseId}
<AppHeader
courseNumber={courseNumber}
courseOrg={courseOrg}
courseTitle={courseTitle}
courseId={courseId}
/>
)
)}

View File

@@ -88,7 +88,7 @@ const CourseAuthoringRoutes = () => {
/>
<Route
path="editor/:blockType/:blockId?"
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
element={<PageWrap><EditorContainer courseId={courseId} /></PageWrap>}
/>
<Route
path="settings/details"
@@ -124,7 +124,7 @@ const CourseAuthoringRoutes = () => {
/>
<Route
path="certificates"
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates courseId={courseId} /></PageWrap> : null}
element={<PageWrap><Certificates courseId={courseId} /></PageWrap>}
/>
<Route
path="textbooks"

View File

@@ -21,10 +21,9 @@ jest.mock('react-router-dom', () => ({
}),
}));
// Mock the TinyMceWidget
jest.mock('./editors/sharedComponents/TinyMceWidget', () => ({
__esModule: true, // Required to mock a default export
default: () => <div>Widget</div>,
// 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,

View File

@@ -2,7 +2,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
pageTitle: {
id: 'course-authoring.accessibility.page.title',
id: 'course-authoring.import.page.title',
defaultMessage: 'Studio Accessibility Policy| {siteName}',
},
});

View File

@@ -6,7 +6,7 @@ import {
} from '@openedx/paragon';
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import Placeholder from '../editors/Placeholder';
import Placeholder from '@edx/frontend-lib-content-components';
import AlertProctoringError from '../generic/AlertProctoringError';
import { useModel } from '../generic/model-store';

View File

@@ -79,7 +79,3 @@
color: $black;
}
}
.react-datepicker-popper {
z-index: 3;
}

View File

@@ -9,7 +9,3 @@
.mw-300px {
max-width: 300px;
}
.right-0 {
right: 0;
}

View File

@@ -1,7 +1,7 @@
import { Helmet } from 'react-helmet';
import PropTypes from 'prop-types';
import Placeholder from '@edx/frontend-lib-content-components';
import Placeholder from '../editors/Placeholder';
import { RequestStatus } from '../data/constants';
import Loading from '../generic/Loading';
import useCertificates from './hooks/useCertificates';

View File

@@ -59,10 +59,10 @@ describe('HeaderButtons Component', () => {
expect(previewLink).toHaveAttribute('href', expect.stringContaining(certificatesDataMock.courseModes[0]));
const dropdownButton = getByRole('button', { name: certificatesDataMock.courseModes[0] });
userEvent.click(dropdownButton);
await userEvent.click(dropdownButton);
const verifiedMode = await getByRole('button', { name: certificatesDataMock.courseModes[1] });
userEvent.click(verifiedMode);
await userEvent.click(verifiedMode);
await waitFor(() => {
expect(previewLink).toHaveAttribute('href', expect.stringContaining(certificatesDataMock.courseModes[1]));
@@ -78,7 +78,7 @@ describe('HeaderButtons Component', () => {
const { getByRole, queryByRole } = renderComponent();
const activationButton = getByRole('button', { name: messages.headingActionsActivate.defaultMessage });
userEvent.click(activationButton);
await userEvent.click(activationButton);
axiosMock.onPost(
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
@@ -110,7 +110,7 @@ describe('HeaderButtons Component', () => {
const { getByRole, queryByRole } = renderComponent();
const deactivateButton = getByRole('button', { name: messages.headingActionsDeactivate.defaultMessage });
userEvent.click(deactivateButton);
await userEvent.click(deactivateButton);
axiosMock.onPost(
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),

View File

@@ -69,8 +69,3 @@ export const CLIPBOARD_STATUS = {
};
export const STRUCTURAL_XBLOCK_TYPES = ['vertical', 'sequential', 'chapter', 'course'];
export const REGEX_RULES = {
specialCharsRule: /^[a-zA-Z0-9_\-.'*~\s]+$/,
noSpaceRule: /^\S*$/,
};

View File

@@ -5,24 +5,24 @@ import type {} from 'react-select/base';
// and add our custom property 'myCustomProp' to it.
export interface TagTreeEntry {
explicit: boolean;
children: Record<string, TagTreeEntry>;
canChangeObjecttag: boolean;
canDeleteObjecttag: boolean;
explicit: boolean;
children: Record<string, TagTreeEntry>;
canChangeObjecttag: boolean;
canDeleteObjecttag: boolean;
}
export interface TaxonomySelectProps {
taxonomyId: number;
searchTerm: string;
appliedContentTagsTree: Record<string, TagTreeEntry>;
stagedContentTagsTree: Record<string, TagTreeEntry>;
checkedTags: string[];
selectCancelRef: Ref,
selectAddRef: Ref,
selectInlineAddRef: Ref,
handleCommitStagedTags: () => void;
handleCancelStagedTags: () => void;
handleSelectableBoxChange: React.ChangeEventHandler;
taxonomyId: number;
searchTerm: string;
appliedContentTagsTree: Record<string, TagTreeEntry>;
stagedContentTagsTree: Record<string, TagTreeEntry>;
checkedTags: string[];
selectCancelRef: Ref,
selectAddRef: Ref,
selectInlineAddRef: Ref,
handleCommitStagedTags: () => void;
handleCancelStagedTags: () => void;
handleSelectableBoxChange: React.ChangeEventHandler;
}
// Unfortunately the only way to specify the custom props we pass into React Select
@@ -32,8 +32,11 @@ export interface TaxonomySelectProps {
// we should change to using a 'react context' to share this data within <ContentTagsCollapsible>,
// rather than using the custom <Select> Props (selectProps).
declare module 'react-select/base' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export interface Props<Option, IsMulti extends boolean, Group extends GroupBase<Option>> extends TaxonomySelectProps {
export interface Props<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
> extends TaxonomySelectProps {
}
}

View File

@@ -12,10 +12,9 @@ import {
Icon,
} from '@openedx/paragon';
import { Tag, KeyboardArrowDown, KeyboardArrowUp } from '@openedx/paragon/icons';
import { SelectableBox } from '@edx/frontend-lib-content-components';
import { useIntl } from '@edx/frontend-platform/i18n';
import { debounce } from 'lodash';
import SelectableBox from '../editors/sharedComponents/SelectableBox';
import messages from './messages';
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
@@ -75,7 +74,7 @@ const CustomMenu = (props) => {
<div className="d-flex flex-row justify-content-end">
<div className="d-inline">
<Button
tabIndex={0}
tabIndex="0"
ref={selectCancelRef}
variant="tertiary"
className="tags-drawer-cancel-button"
@@ -84,7 +83,7 @@ const CustomMenu = (props) => {
{ intl.formatMessage(messages.collapsibleCancelStagedTagsButtonText) }
</Button>
<Button
tabIndex={0}
tabIndex="0"
ref={selectAddRef}
variant="tertiary"
className="text-info-500 add-tags-button"
@@ -140,7 +139,7 @@ const CustomIndicatorsContainer = (props) => {
onClick={handleCommitStagedTags}
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
ref={selectInlineAddRef}
tabIndex={0}
tabIndex="0"
onKeyDown={disableActionKeys} // To prevent navigating staged tags when button focused
>
{ intl.formatMessage(messages.collapsibleInlineAddStagedTagsButtonText) }
@@ -241,7 +240,7 @@ const ContentTagsCollapsible = ({
const selectCancelRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
const selectAddRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
const selectInlineAddRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
const selectInlineEditModeRef = React.useRef(/** @type {HTMLButtonElement | null} */(null));
const selectInlineEditModeRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
const selectRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
const [selectMenuIsOpen, setSelectMenuIsOpen] = React.useState(false);
@@ -393,18 +392,16 @@ const ContentTagsCollapsible = ({
&& (
<div className="mb-3" key={taxonomyId}>
<p className="text-gray-500">{intl.formatMessage(messages.collapsibleNoTagsAddedText)}
{canTagObject && (
<Button
tabIndex={0}
size="inline"
ref={selectInlineEditModeRef}
variant="link"
className="text-info-500 add-tags-button"
onClick={toEditMode}
>
{ intl.formatMessage(messages.collapsibleAddStagedTagsButtonText) }
</Button>
)}
<Button
tabIndex="0"
size="inline"
ref={selectInlineEditModeRef}
variant="link"
className="text-info-500 add-tags-button"
onClick={toEditMode}
>
{ intl.formatMessage(messages.collapsibleAddStagedTagsButtonText) }
</Button>
</p>
</div>
)}
@@ -420,7 +417,7 @@ const ContentTagsCollapsible = ({
)}
<div className="d-flex taxonomy-tags-selector-menu">
{isEditMode && (
{isEditMode && canTagObject && (
<Select
onBlur={handleOnBlur}
styles={{

View File

@@ -280,30 +280,6 @@ describe('<ContentTagsCollapsible />', () => {
expect(data.toEditMode).toHaveBeenCalledTimes(1);
});
it('should not render "add tags" button when expanded and not allowed to tag objects', async () => {
await getComponent({
...data,
isEditMode: false,
taxonomyAndTagsData: {
id: 123,
name: 'Taxonomy 1',
canTagObject: false,
contentTags: [],
},
});
const expandToggle = screen.getByRole('button', {
name: /taxonomy 1/i,
});
fireEvent.click(expandToggle);
expect(screen.queryByText(/no tags added yet/i)).toBeInTheDocument();
const addTags = screen.queryByRole('button', {
name: /add tags/i,
});
expect(addTags).not.toBeInTheDocument();
});
it('should call `openCollapsible` when click in the collapsible', async () => {
await getComponent({
...data,
@@ -420,7 +396,7 @@ describe('<ContentTagsCollapsible />', () => {
expect(data.removeGlobalStagedContentTag).toHaveBeenCalledWith(taxonomyId, 'Tag 3');
});
it('should call `addRemovedContentTag` when a fetched tag is deleted', async () => {
it('should call `addRemovedContentTag` when a feched tag is deleted', async () => {
await getComponent();
const tag = screen.getByText(/tag 2/i);

View File

@@ -116,7 +116,7 @@ const useContentTagsCollapsibleHelper = (
// State to keep track of the staged tags (and along with ancestors) that should be removed
const [stagedTagsToRemove, setStagedTagsToRemove] = React.useState(/** @type string[] */([]));
// State to keep track of the global tags (staged and fetched) that should be removed
// State to keep track of the global tags (stagged and feched) that should be removed
const [globalTagsToRemove, setGlobalTagsToRemove] = React.useState(/** @type string[] */([]));
// Handles the removal of staged content tags based on what was removed
@@ -140,7 +140,7 @@ const useContentTagsCollapsibleHelper = (
// A new tag has been removed
removeGlobalStagedContentTag(id, tag);
} else if (contentTags.some(t => t.value === tag)) {
// A fetched tag has been removed
// A feched tag has been removed
addRemovedContentTag(id, tag);
}
});
@@ -157,7 +157,7 @@ const useContentTagsCollapsibleHelper = (
explicitStaged.forEach((tag) => {
if (globalStagedRemovedContentTags[id]
&& globalStagedRemovedContentTags[id].includes(tag.value)) {
// A fetched tag that has been removed has been added again
// A feched tag that has been removed has been added again
deleteRemovedContentTag(id, tag.value);
} else {
// New tag added
@@ -298,7 +298,7 @@ const useContentTagsCollapsibleHelper = (
traversal[tag].lineage = tagLineage;
}
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
// eslint-disable-next-line no-unused-expressions
isExplicit ? add(value.join(',')) : remove(value.join(','));
traversal = traversal[tag].children;
});

View File

@@ -0,0 +1,263 @@
// @ts-check
import React, { useContext, useEffect } from 'react';
import PropTypes from 'prop-types';
import {
Container,
Spinner,
Stack,
Button,
Toast,
} from '@openedx/paragon';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { useParams, useNavigate } from 'react-router-dom';
import messages from './messages';
import ContentTagsCollapsible from './ContentTagsCollapsible';
import Loading from '../generic/Loading';
import useContentTagsDrawerContext from './ContentTagsDrawerHelper';
import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context';
const TaxonomyList = ({ contentId }) => {
const navigate = useNavigate();
const intl = useIntl();
const {
isTaxonomyListLoaded,
isContentTaxonomyTagsLoaded,
tagsByTaxonomy,
stagedContentTags,
collapsibleStates,
} = React.useContext(ContentTagsDrawerContext);
if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) {
if (tagsByTaxonomy.length !== 0) {
return (
<div>
{ tagsByTaxonomy.map((data) => (
<div key={`taxonomy-tags-collapsible-${data.id}`}>
<ContentTagsCollapsible
contentId={contentId}
taxonomyAndTagsData={data}
stagedContentTags={stagedContentTags[data.id] || []}
collapsibleState={collapsibleStates[data.id] || false}
/>
<hr />
</div>
))}
</div>
);
}
return (
<FormattedMessage
{...messages.emptyDrawerContent}
values={{
link: (
<Button
tabIndex="0"
size="inline"
variant="link"
className="text-info-500 p-0 enable-taxonomies-button"
onClick={() => navigate('/taxonomies')}
>
{ intl.formatMessage(messages.emptyDrawerContentLink) }
</Button>
),
}}
/>
);
}
return <Loading />;
};
TaxonomyList.propTypes = {
contentId: PropTypes.string.isRequired,
};
/**
* Drawer with the functionality to show and manage tags in a certain content.
* It is used both in interfaces of this MFE and in edx-platform interfaces such as iframe.
* - If you want to use it as an iframe, the component obtains the `contentId` from the url parameters.
* Functions to close the drawer are handled internally.
* TODO: We can delete this method when is no longer used on edx-platform.
* - If you want to use it as react component, you need to pass the content id and the close functions
* through the component parameters.
*/
const ContentTagsDrawer = ({ id, onClose }) => {
const intl = useIntl();
// TODO: We can delete 'params' when the iframe is no longer used on edx-platform
const params = useParams();
const contentId = id ?? params.contentId;
const context = useContentTagsDrawerContext(contentId);
const { blockingSheet } = useContext(ContentTagsDrawerSheetContext);
const {
showToastAfterSave,
toReadMode,
commitGlobalStagedTagsStatus,
isContentDataLoaded,
contentName,
isTaxonomyListLoaded,
isContentTaxonomyTagsLoaded,
stagedContentTags,
collapsibleStates,
isEditMode,
commitGlobalStagedTags,
toEditMode,
toastMessage,
closeToast,
setCollapsibleToInitalState,
otherTaxonomies,
} = context;
let onCloseDrawer = onClose;
if (onCloseDrawer === undefined) {
onCloseDrawer = () => {
// "*" 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 && !blockingSheet) {
onCloseDrawer();
}
};
document.addEventListener('keydown', handleEsc);
return () => {
document.removeEventListener('keydown', handleEsc);
};
}, [blockingSheet]);
useEffect(() => {
/* istanbul ignore next */
if (commitGlobalStagedTagsStatus === 'success') {
showToastAfterSave();
toReadMode();
}
}, [commitGlobalStagedTagsStatus]);
// First call of the initial collapsible states
React.useEffect(() => {
setCollapsibleToInitalState();
}, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded]);
return (
<ContentTagsDrawerContext.Provider value={context}>
<div id="content-tags-drawer" className="mt-1 tags-drawer d-flex flex-column justify-content-between min-vh-100 pt-3">
<Container size="xl">
{ isContentDataLoaded
? <h2 className="h3 pl-2.5">{ contentName }</h2>
: (
<div className="d-flex justify-content-center align-items-center flex-column">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
</div>
)}
<hr />
<Container>
<p className="h4 text-gray-500 font-weight-bold">
{intl.formatMessage(messages.headerSubtitle)}
</p>
<TaxonomyList contentId={contentId} />
{otherTaxonomies.length !== 0 && (
<div>
<p className="h4 text-gray-500 font-weight-bold">
{intl.formatMessage(messages.otherTagsHeader)}
</p>
<p className="other-description text-gray-500">
{intl.formatMessage(messages.otherTagsDescription)}
</p>
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded && (
otherTaxonomies.map((data) => (
<div key={`taxonomy-tags-collapsible-${data.id}`}>
<ContentTagsCollapsible
contentId={contentId}
taxonomyAndTagsData={data}
stagedContentTags={stagedContentTags[data.id] || []}
collapsibleState={collapsibleStates[data.id] || false}
/>
<hr />
</div>
))
)}
</div>
)}
</Container>
</Container>
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded && (
<Container
className="bg-white position-sticky p-3.5 box-shadow-up-2 tags-drawer-footer"
>
<div className="d-flex justify-content-end">
{ commitGlobalStagedTagsStatus !== 'loading' ? (
<Stack direction="horizontal" gap={2}>
<Button
className="font-weight-bold tags-drawer-cancel-button"
variant="tertiary"
onClick={isEditMode
? toReadMode
: onCloseDrawer}
>
{ intl.formatMessage(isEditMode
? messages.tagsDrawerCancelButtonText
: messages.tagsDrawerCloseButtonText)}
</Button>
<Button
variant="dark"
className="rounded-0"
onClick={isEditMode
? commitGlobalStagedTags
: toEditMode}
>
{ intl.formatMessage(isEditMode
? messages.tagsDrawerSaveButtonText
: messages.tagsDrawerEditTagsButtonText)}
</Button>
</Stack>
)
: (
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
)}
</div>
</Container>
)}
{/* istanbul ignore next */
toastMessage && (
<Toast
show
onClose={closeToast}
>
{toastMessage}
</Toast>
)
}
</div>
</ContentTagsDrawerContext.Provider>
);
};
ContentTagsDrawer.propTypes = {
id: PropTypes.string,
onClose: PropTypes.func,
};
ContentTagsDrawer.defaultProps = {
id: undefined,
onClose: undefined,
};
export default ContentTagsDrawer;

View File

@@ -2,7 +2,7 @@
min-width: max(500px, 33vw);
}
@media only screen and (width <= 500px) {
@media only screen and (max-width: 500px) {
.pgn__sheet-component:has(#content-tags-drawer) {
min-width: 100vw;
}

View File

@@ -1,119 +1,589 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
act,
fireEvent,
initializeMocks,
render,
waitFor,
screen,
within,
} from '../testUtils';
} from '@testing-library/react';
import ContentTagsDrawer from './ContentTagsDrawer';
import {
useContentTaxonomyTagsData,
useContentData,
useTaxonomyTagsData,
useContentTaxonomyTagsUpdater,
} from './data/apiHooks';
import { getTaxonomyListData } from '../taxonomy/data/api';
import messages from './messages';
import { ContentTagsDrawerSheetContext } from './common/context';
import {
mockContentData,
mockContentTaxonomyTagsData,
mockTaxonomyListData,
mockTaxonomyTagsData,
} from './data/api.mocks';
import { getContentTaxonomyTagsApiUrl } from './data/api';
import { languageExportId } from './utils';
const path = '/content/:contentId/*';
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab';
const mockOnClose = jest.fn();
const mockMutate = jest.fn();
const mockSetBlockingSheet = jest.fn();
const mockNavigate = jest.fn();
mockContentTaxonomyTagsData.applyMock();
mockTaxonomyListData.applyMock();
mockTaxonomyTagsData.applyMock();
mockContentData.applyMock();
const {
stagedTagsId,
otherTagsId,
languageWithTagsId,
languageWithoutTagsId,
largeTagsId,
emptyTagsId,
} = mockContentTaxonomyTagsData;
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
contentId,
}),
useNavigate: () => mockNavigate,
}));
const renderDrawer = (contentId, drawerParams = {}) => (
render(
<ContentTagsDrawerSheetContext.Provider value={drawerParams}>
<ContentTagsDrawer {...drawerParams} />
</ContentTagsDrawerSheetContext.Provider>,
{ path, params: { contentId } },
)
// FIXME: replace these mocks with API mocks
jest.mock('./data/apiHooks', () => ({
useContentTaxonomyTagsData: jest.fn(() => {}),
useContentData: jest.fn(() => ({
isSuccess: false,
data: {},
})),
useContentTaxonomyTagsUpdater: jest.fn(() => ({
isError: false,
mutate: mockMutate,
})),
useTaxonomyTagsData: jest.fn(() => ({
hasMorePages: false,
tagPages: {
isLoading: true,
isError: false,
canAddTag: false,
data: [],
},
})),
}));
jest.mock('../taxonomy/data/api', () => ({
// By default, the mock taxonomy list will never load (promise never resolves):
getTaxonomyListData: jest.fn(),
}));
const queryClient = new QueryClient();
const RootWrapper = (params) => (
<ContentTagsDrawerSheetContext.Provider value={params}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<ContentTagsDrawer {...params} />
</QueryClientProvider>
</IntlProvider>
</ContentTagsDrawerSheetContext.Provider>
);
describe('<ContentTagsDrawer />', () => {
beforeEach(async () => {
initializeMocks();
jest.clearAllMocks();
await queryClient.resetQueries();
// By default, we mock the API call with a promise that never resolves.
// You can override this in specific test.
getTaxonomyListData.mockReturnValue(new Promise(() => {}));
useContentTaxonomyTagsUpdater.mockReturnValue({
isError: false,
mutate: mockMutate,
});
});
const setupMockDataForStagedTagsTesting = () => {
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,
},
],
},
],
},
});
getTaxonomyListData.mockResolvedValue({
results: [
{
id: 123,
name: 'Taxonomy 1',
description: 'This is a description 1',
canTagObject: true,
},
],
});
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,
}],
},
});
};
const setupMockDataWithOtherTagsTestings = () => {
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: 1234,
canTagObject: false,
tags: [
{
value: 'Tag 3',
lineage: ['Tag 3'],
canDeleteObjecttag: true,
},
{
value: 'Tag 4',
lineage: ['Tag 4'],
canDeleteObjecttag: true,
},
],
},
],
},
});
getTaxonomyListData.mockResolvedValue({
results: [
{
id: 123,
name: 'Taxonomy 1',
description: 'This is a description 1',
canTagObject: true,
},
],
});
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,
}],
},
});
};
const setupMockDataLanguageTaxonomyTestings = (hasTags) => {
useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: {
taxonomies: [
{
name: 'Languages',
taxonomyId: 123,
exportId: languageExportId,
canTagObject: true,
tags: hasTags ? [
{
value: 'Tag 1',
lineage: ['Tag 1'],
canDeleteObjecttag: true,
},
] : [],
},
{
name: 'Taxonomy 1',
taxonomyId: 1234,
canTagObject: true,
tags: [
{
value: 'Tag 1',
lineage: ['Tag 1'],
canDeleteObjecttag: true,
},
{
value: 'Tag 2',
lineage: ['Tag 2'],
canDeleteObjecttag: true,
},
],
},
],
},
});
getTaxonomyListData.mockResolvedValue({
results: [
{
id: 123,
name: 'Languages',
description: 'This is a description 1',
exportId: languageExportId,
canTagObject: true,
},
{
id: 1234,
name: 'Taxonomy 1',
description: 'This is a description 2',
canTagObject: true,
},
],
});
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,
}],
},
});
};
const setupLargeMockDataForStagedTagsTesting = () => {
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 1',
lineage: ['Tag 1'],
canDeleteObjecttag: true,
},
],
},
{
name: 'Taxonomy 3',
taxonomyId: 125,
canTagObject: true,
tags: [
{
value: 'Tag 1.1.1',
lineage: ['Tag 1', 'Tag 1.1', 'Tag 1.1.1'],
canDeleteObjecttag: true,
},
],
},
{
name: '(B) Taxonomy 4',
taxonomyId: 126,
canTagObject: true,
tags: [],
},
{
name: '(A) Taxonomy 5',
taxonomyId: 127,
canTagObject: true,
tags: [],
},
],
},
});
getTaxonomyListData.mockResolvedValue({
results: [
{
id: 123,
name: 'Taxonomy 1',
description: 'This is a description 1',
canTagObject: true,
},
{
id: 124,
name: 'Taxonomy 2',
description: 'This is a description 2',
canTagObject: true,
},
{
id: 125,
name: 'Taxonomy 3',
description: 'This is a description 3',
canTagObject: true,
},
{
id: 127,
name: '(A) Taxonomy 5',
description: 'This is a description 5',
canTagObject: true,
},
{
id: 126,
name: '(B) Taxonomy 4',
description: 'This is a description 4',
canTagObject: true,
},
],
});
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 page and page title correctly', () => {
renderDrawer(stagedTagsId);
expect(screen.getByText('Manage tags')).toBeInTheDocument();
setupMockDataForStagedTagsTesting();
const { getByText } = render(<RootWrapper />);
expect(getByText('Manage tags')).toBeInTheDocument();
});
it('shows spinner before the content data query is complete', async () => {
await act(async () => {
renderDrawer(stagedTagsId);
const spinner = screen.getAllByRole('status')[0];
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 () => {
await act(async () => {
renderDrawer(stagedTagsId);
const spinner = screen.getAllByRole('status')[1];
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 in drawer variant', async () => {
renderDrawer('test');
expect(await screen.findByText('Loading...')).toBeInTheDocument();
expect(await screen.findByText('Unit 1')).toBeInTheDocument();
expect(await screen.findByText('Manage tags')).toBeInTheDocument();
});
it('shows the content display name after the query is complete in component variant', async () => {
renderDrawer('test', { variant: 'component' });
expect(await screen.findByText('Loading...')).toBeInTheDocument();
expect(screen.queryByText('Unit 1')).not.toBeInTheDocument();
expect(screen.queryByText('Manage tags')).not.toBeInTheDocument();
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 content using params', async () => {
renderDrawer(undefined, { id: 'test' });
expect(await screen.findByText('Loading...')).toBeInTheDocument();
expect(await screen.findByText('Unit 1')).toBeInTheDocument();
expect(await screen.findByText('Manage tags')).toBeInTheDocument();
useContentData.mockReturnValue({
isSuccess: true,
data: {
displayName: 'Unit 1',
},
});
render(<RootWrapper id={contentId} />);
expect(screen.getByText('Unit 1')).toBeInTheDocument();
});
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
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,
},
],
},
],
},
});
getTaxonomyListData.mockResolvedValue({
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 } = renderDrawer(largeTagsId);
await waitFor(() => { expect(screen.getByText('Taxonomy 1')).toBeInTheDocument(); });
expect(screen.getByText('Taxonomy 1')).toBeInTheDocument();
expect(screen.getByText('Taxonomy 2')).toBeInTheDocument();
const { container, getByText } = render(<RootWrapper />);
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(getByText('Taxonomy 2')).toBeInTheDocument();
const tagCountBadges = container.getElementsByClassName('taxonomy-tags-count-chip');
expect(tagCountBadges[0].textContent).toBe('3');
expect(tagCountBadges[1].textContent).toBe('2');
expect(tagCountBadges[0].textContent).toBe('2');
expect(tagCountBadges[1].textContent).toBe('1');
});
});
it('should be read only on first render on drawer variant', async () => {
renderDrawer(stagedTagsId);
it('should be read only on first render', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /close/i }));
expect(screen.getByRole('button', { name: /edit tags/i }));
// Not show delete tag buttons
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
@@ -128,26 +598,9 @@ describe('<ContentTagsDrawer />', () => {
expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
});
it('should be read only on first render on component variant', async () => {
renderDrawer(stagedTagsId, { variant: 'component' });
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /manage tags/i }));
// Not show delete tag buttons
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
// Not show add a tag select
expect(screen.queryByText(/add a tag/i)).not.toBeInTheDocument();
// Not show cancel button
expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument();
// Not show save button
expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
});
it('should change to edit mode when click on `Edit tags` on drawer variant', async () => {
renderDrawer(stagedTagsId);
it('should change to edit mode when click on `Edit tags`', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
const editTagsButton = screen.getByRole('button', {
name: /edit tags/i,
@@ -169,31 +622,9 @@ describe('<ContentTagsDrawer />', () => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('should change to edit mode when click on `Manage tags` on component variant', async () => {
renderDrawer(stagedTagsId, { variant: 'component' });
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
const manageTagsButton = screen.getByRole('button', {
name: /manage tags/i,
});
fireEvent.click(manageTagsButton);
// Show delete tag buttons
expect(screen.getAllByRole('button', {
name: /delete/i,
}).length).toBe(2);
// Show add a tag select
expect(screen.getByText(/add a tag/i)).toBeInTheDocument();
// Show cancel button
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
// Show save button
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('should change to read mode when click on `Cancel` on drawer variant', async () => {
renderDrawer(stagedTagsId);
it('should change to read mode when click on `Cancel`', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
const editTagsButton = screen.getByRole('button', {
name: /edit tags/i,
@@ -218,57 +649,21 @@ describe('<ContentTagsDrawer />', () => {
expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
});
it('should change to read mode when click on `Cancel` on component variant', async () => {
renderDrawer(stagedTagsId, { variant: 'component' });
it('shows spinner when loading commit tags', async () => {
setupMockDataForStagedTagsTesting();
useContentTaxonomyTagsUpdater.mockReturnValue({
status: 'loading',
isError: false,
mutate: mockMutate,
});
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
const manageTagsButton = screen.getByRole('button', {
name: /manage tags/i,
});
fireEvent.click(manageTagsButton);
const cancelButton = screen.getByRole('button', {
name: /cancel/i,
});
fireEvent.click(cancelButton);
// Not show delete tag buttons
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
// Not show add a tag select
expect(screen.queryByText(/add a tag/i)).not.toBeInTheDocument();
// Not show cancel button
expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument();
// Not show save button
expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
expect(screen.getByRole('status')).toBeInTheDocument();
});
test.each([
{
variant: 'drawer',
editButton: /edit tags/i,
},
{
variant: 'component',
editButton: /manage tags/i,
},
])(
'should hide "$editButton" button on $variant variant if not allowed to tag object',
async ({ variant, editButton }) => {
renderDrawer(stagedTagsId, { variant, readOnly: true });
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: editButton })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
expect(screen.queryByText(/add a tag/i)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
},
);
it('should test adding a content tag to the staged tags for a taxonomy', async () => {
renderDrawer(stagedTagsId);
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// To edit mode
@@ -283,7 +678,7 @@ describe('<ContentTagsDrawer />', () => {
fireEvent.mouseDown(addTagsButton);
// Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied)
expect((await screen.findAllByText('Tag 3')).length).toBe(1);
expect(screen.getAllByText('Tag 3').length).toBe(1);
// Click to check Tag 3
const tag3 = screen.getByText('Tag 3');
@@ -294,7 +689,8 @@ describe('<ContentTagsDrawer />', () => {
});
it('should test removing a staged content from a taxonomy', async () => {
renderDrawer(stagedTagsId);
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// To edit mode
@@ -309,7 +705,7 @@ describe('<ContentTagsDrawer />', () => {
fireEvent.mouseDown(addTagsButton);
// Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied)
expect((await screen.findAllByText('Tag 3')).length).toBe(1);
expect(screen.getAllByText('Tag 3').length).toBe(1);
// Click to check Tag 3
const tag3 = screen.getByText('Tag 3');
@@ -324,9 +720,11 @@ describe('<ContentTagsDrawer />', () => {
});
it('should test clearing staged tags for a taxonomy', async () => {
setupMockDataForStagedTagsTesting();
const {
container,
} = renderDrawer(stagedTagsId);
} = render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// To edit mode
@@ -341,7 +739,7 @@ describe('<ContentTagsDrawer />', () => {
fireEvent.mouseDown(addTagsButton);
// Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied)
expect((await screen.findAllByText('Tag 3')).length).toBe(1);
expect(screen.getAllByText('Tag 3').length).toBe(1);
// Click to check Tag 3
const tag3 = screen.getByText('Tag 3');
@@ -360,7 +758,8 @@ describe('<ContentTagsDrawer />', () => {
});
it('should test adding global staged tags and cancel', async () => {
renderDrawer(stagedTagsId);
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// To edit mode
@@ -375,7 +774,7 @@ describe('<ContentTagsDrawer />', () => {
fireEvent.mouseDown(addTagsButton);
// Click to check Tag 3
const tag3 = await screen.findByText(/tag 3/i);
const tag3 = screen.getByText(/tag 3/i);
fireEvent.click(tag3);
// Click "Add tags" to save to global staged tags
@@ -391,8 +790,9 @@ describe('<ContentTagsDrawer />', () => {
expect(screen.queryByText(/tag 3/i)).not.toBeInTheDocument();
});
it('should test delete fetched tags and cancel', async () => {
renderDrawer(stagedTagsId);
it('should test delete feched tags and cancel', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// To edit mode
@@ -402,7 +802,7 @@ describe('<ContentTagsDrawer />', () => {
fireEvent.click(editTagsButton);
// Delete the tag
const tag = await screen.findByText(/tag 2/i);
const tag = screen.getByText(/tag 2/i);
const deleteButton = within(tag).getByRole('button', {
name: /delete/i,
});
@@ -418,7 +818,8 @@ describe('<ContentTagsDrawer />', () => {
});
it('should test delete global staged tags and cancel', async () => {
renderDrawer(stagedTagsId);
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// To edit mode
@@ -433,7 +834,7 @@ describe('<ContentTagsDrawer />', () => {
fireEvent.mouseDown(addTagsButton);
// Click to check Tag 3
const tag3 = await screen.findByText(/tag 3/i);
const tag3 = screen.getByText(/tag 3/i);
fireEvent.click(tag3);
// Click "Add tags" to save to global staged tags
@@ -458,8 +859,9 @@ describe('<ContentTagsDrawer />', () => {
expect(screen.queryByText(/tag 3/i)).not.toBeInTheDocument();
});
it('should test add removed fetched tags and cancel', async () => {
renderDrawer(stagedTagsId);
it('should test add removed feched tags and cancel', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// To edit mode
@@ -469,7 +871,7 @@ describe('<ContentTagsDrawer />', () => {
fireEvent.click(editTagsButton);
// Delete the tag
const tag = await screen.findByText(/tag 2/i);
const tag = screen.getByText(/tag 2/i);
const deleteButton = within(tag).getByRole('button', {
name: /delete/i,
});
@@ -483,7 +885,7 @@ describe('<ContentTagsDrawer />', () => {
fireEvent.mouseDown(addTagsButton);
// Click to check Tag 2
const tag2 = await screen.findByText(/tag 2/i);
const tag2 = screen.getByText(/tag 2/i);
fireEvent.click(tag2);
// Click "Add tags" to save to global staged tags
@@ -500,7 +902,8 @@ describe('<ContentTagsDrawer />', () => {
});
it('should call onClose when cancel is clicked', async () => {
renderDrawer(stagedTagsId, { onClose: mockOnClose });
setupMockDataForStagedTagsTesting();
render(<RootWrapper onClose={mockOnClose} />);
const cancelButton = await screen.findByRole('button', {
name: /close/i,
@@ -514,7 +917,7 @@ describe('<ContentTagsDrawer />', () => {
it('should call closeManageTagsDrawer when Escape key is pressed and no selectable box is active', () => {
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
const { container } = renderDrawer(stagedTagsId);
const { container } = render(<RootWrapper />);
fireEvent.keyDown(container, {
key: 'Escape',
@@ -526,7 +929,7 @@ describe('<ContentTagsDrawer />', () => {
});
it('should call `onClose` when Escape key is pressed and no selectable box is active', () => {
const { container } = renderDrawer(stagedTagsId, { onClose: mockOnClose });
const { container } = render(<RootWrapper onClose={mockOnClose} />);
fireEvent.keyDown(container, {
key: 'Escape',
@@ -538,7 +941,7 @@ describe('<ContentTagsDrawer />', () => {
it('should not call closeManageTagsDrawer when Escape key is pressed and a selectable box is active', () => {
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
const { container } = renderDrawer(stagedTagsId);
const { container } = render(<RootWrapper />);
// Simulate that the selectable box is open by adding an element with the data attribute
const selectableBox = document.createElement('div');
@@ -558,7 +961,7 @@ describe('<ContentTagsDrawer />', () => {
});
it('should not call `onClose` when Escape key is pressed and a selectable box is active', () => {
const { container } = renderDrawer(stagedTagsId, { onClose: mockOnClose });
const { container } = render(<RootWrapper onClose={mockOnClose} />);
// Simulate that the selectable box is open by adding an element with the data attribute
const selectableBox = document.createElement('div');
@@ -577,7 +980,8 @@ describe('<ContentTagsDrawer />', () => {
it('should not call closeManageTagsDrawer when Escape key is pressed and container is blocked', () => {
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
const { container } = renderDrawer(stagedTagsId, { blockingSheet: true });
const { container } = render(<RootWrapper blockingSheet />);
fireEvent.keyDown(container, {
key: 'Escape',
});
@@ -588,10 +992,7 @@ describe('<ContentTagsDrawer />', () => {
});
it('should not call `onClose` when Escape key is pressed and container is blocked', () => {
const { container } = renderDrawer(stagedTagsId, {
blockingSheet: true,
onClose: mockOnClose,
});
const { container } = render(<RootWrapper blockingSheet onClose={mockOnClose} />);
fireEvent.keyDown(container, {
key: 'Escape',
});
@@ -600,10 +1001,8 @@ describe('<ContentTagsDrawer />', () => {
});
it('should call `setBlockingSheet` on add a tag', async () => {
renderDrawer(stagedTagsId, {
blockingSheet: true,
setBlockingSheet: mockSetBlockingSheet,
});
setupMockDataForStagedTagsTesting();
render(<RootWrapper blockingSheet setBlockingSheet={mockSetBlockingSheet} />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
expect(mockSetBlockingSheet).toHaveBeenCalledWith(false);
@@ -620,7 +1019,7 @@ describe('<ContentTagsDrawer />', () => {
fireEvent.mouseDown(addTagsButton);
// Click to check Tag 3
const tag3 = await screen.findByText(/tag 3/i);
const tag3 = screen.getByText(/tag 3/i);
fireEvent.click(tag3);
// Click "Add tags" to save to global staged tags
@@ -631,10 +1030,8 @@ describe('<ContentTagsDrawer />', () => {
});
it('should call `setBlockingSheet` on delete a tag', async () => {
renderDrawer(stagedTagsId, {
blockingSheet: true,
setBlockingSheet: mockSetBlockingSheet,
});
setupMockDataForStagedTagsTesting();
render(<RootWrapper blockingSheet setBlockingSheet={mockSetBlockingSheet} />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
expect(mockSetBlockingSheet).toHaveBeenCalledWith(false);
@@ -656,10 +1053,8 @@ describe('<ContentTagsDrawer />', () => {
});
it('should call `updateTags` mutation on save', async () => {
const { axiosMock } = initializeMocks();
const url = getContentTaxonomyTagsApiUrl(stagedTagsId);
axiosMock.onPut(url).reply(200);
renderDrawer(stagedTagsId);
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
const editTagsButton = screen.getByRole('button', {
name: /edit tags/i,
@@ -671,11 +1066,12 @@ describe('<ContentTagsDrawer />', () => {
});
fireEvent.click(saveButton);
await waitFor(() => expect(axiosMock.history.put[0].url).toEqual(url));
expect(mockMutate).toHaveBeenCalled();
});
it('should taxonomies must be ordered', async () => {
renderDrawer(largeTagsId);
setupLargeMockDataForStagedTagsTesting();
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// First, taxonomies with content sorted by count implicit
@@ -695,14 +1091,18 @@ describe('<ContentTagsDrawer />', () => {
});
it('should not show "Other tags" section', async () => {
renderDrawer(stagedTagsId);
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
expect(screen.queryByText('Other tags')).not.toBeInTheDocument();
});
it('should show "Other tags" section', async () => {
renderDrawer(otherTagsId);
setupMockDataWithOtherTagsTestings();
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
expect(screen.getByText('Other tags')).toBeInTheDocument();
@@ -712,7 +1112,8 @@ describe('<ContentTagsDrawer />', () => {
});
it('should test delete "Other tags" and cancel', async () => {
renderDrawer(otherTagsId);
setupMockDataWithOtherTagsTestings();
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 2')).toBeInTheDocument();
// To edit mode
@@ -738,18 +1139,40 @@ describe('<ContentTagsDrawer />', () => {
});
it('should show Language Taxonomy', async () => {
renderDrawer(languageWithTagsId);
setupMockDataLanguageTaxonomyTestings(true);
render(<RootWrapper />);
expect(await screen.findByText('Languages')).toBeInTheDocument();
});
it('should hide Language Taxonomy', async () => {
renderDrawer(languageWithoutTagsId);
setupMockDataLanguageTaxonomyTestings(false);
render(<RootWrapper />);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
expect(screen.queryByText('Languages')).not.toBeInTheDocument();
});
it('should show empty drawer message', async () => {
renderDrawer(emptyTagsId);
useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: {
taxonomies: [],
},
});
getTaxonomyListData.mockResolvedValue({
results: [],
});
useTaxonomyTagsData.mockReturnValue({
hasMorePages: false,
canAddTag: false,
tagPages: {
isLoading: false,
isError: false,
data: [],
},
});
render(<RootWrapper />);
expect(await screen.findByText(/to use tags, please or contact your administrator\./i)).toBeInTheDocument();
const enableButton = screen.getByRole('button', {
name: /enable a taxonomy/i,

View File

@@ -1,399 +0,0 @@
import React, { useContext, useEffect } from 'react';
import {
Container,
Spinner,
Stack,
Button,
Toast,
} from '@openedx/paragon';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { useParams, useNavigate } from 'react-router-dom';
import classNames from 'classnames';
import messages from './messages';
import ContentTagsCollapsible from './ContentTagsCollapsible';
import Loading from '../generic/Loading';
import useContentTagsDrawerContext from './ContentTagsDrawerHelper';
import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context';
interface TaxonomyListProps {
contentId: string;
}
const TaxonomyList = ({ contentId }: TaxonomyListProps) => {
const navigate = useNavigate();
const intl = useIntl();
const {
isTaxonomyListLoaded,
isContentTaxonomyTagsLoaded,
tagsByTaxonomy,
stagedContentTags,
collapsibleStates,
} = React.useContext(ContentTagsDrawerContext);
if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) {
if (tagsByTaxonomy.length !== 0) {
return (
<div>
{ tagsByTaxonomy.map((data) => (
<div key={data.id}>
<ContentTagsCollapsible
contentId={contentId}
taxonomyAndTagsData={data}
stagedContentTags={stagedContentTags[data.id] || []}
collapsibleState={collapsibleStates[data.id] || false}
/>
<hr />
</div>
))}
</div>
);
}
return (
<FormattedMessage
{...messages.emptyDrawerContent}
values={{
link: (
<Button
tabIndex={0}
size="inline"
variant="link"
className="text-info-500 p-0 enable-taxonomies-button"
onClick={() => navigate('/taxonomies')}
>
{ intl.formatMessage(messages.emptyDrawerContentLink) }
</Button>
),
}}
/>
);
}
return <Loading />;
};
const ContentTagsDrawerTitle = () => {
const intl = useIntl();
const {
isContentDataLoaded,
contentName,
} = useContext(ContentTagsDrawerContext);
return (
<>
{ isContentDataLoaded
? <h2 className="h3 pl-2.5">{ contentName }</h2>
: (
<div className="d-flex justify-content-center align-items-center flex-column">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
</div>
)}
<hr />
</>
);
};
interface ContentTagsDrawerVariantFooterProps {
onClose: () => void,
readOnly: boolean,
}
const ContentTagsDrawerVariantFooter = ({ onClose, readOnly }: ContentTagsDrawerVariantFooterProps) => {
const intl = useIntl();
const {
commitGlobalStagedTagsStatus,
commitGlobalStagedTags,
isEditMode,
toReadMode,
toEditMode,
} = useContext(ContentTagsDrawerContext);
return (
<Container
className="bg-white position-sticky p-3.5 box-shadow-up-2 tags-drawer-footer"
>
<div className="d-flex justify-content-end">
{ commitGlobalStagedTagsStatus !== 'loading' ? (
<Stack direction="horizontal" gap={2}>
<Button
className="font-weight-bold tags-drawer-cancel-button"
variant="tertiary"
onClick={isEditMode
? toReadMode
: onClose}
>
{ intl.formatMessage(isEditMode
? messages.tagsDrawerCancelButtonText
: messages.tagsDrawerCloseButtonText)}
</Button>
{!readOnly && (
<Button
className="rounded-0"
onClick={isEditMode
? commitGlobalStagedTags
: toEditMode}
>
{ intl.formatMessage(isEditMode
? messages.tagsDrawerSaveButtonText
: messages.tagsDrawerEditTagsButtonText)}
</Button>
)}
</Stack>
)
: (
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
)}
</div>
</Container>
);
};
interface ContentTagsComponentVariantFooterProps {
readOnly?: boolean;
}
const ContentTagsComponentVariantFooter = ({ readOnly = false }: ContentTagsComponentVariantFooterProps) => {
const intl = useIntl();
const {
commitGlobalStagedTagsStatus,
commitGlobalStagedTags,
isEditMode,
toReadMode,
toEditMode,
} = useContext(ContentTagsDrawerContext);
return (
<div>
{isEditMode ? (
<div>
{ commitGlobalStagedTagsStatus !== 'loading' ? (
<Stack direction="horizontal" gap={2}>
<Button
className="font-weight-bold tags-drawer-cancel-button"
variant="tertiary"
onClick={toReadMode}
>
{intl.formatMessage(messages.tagsDrawerCancelButtonText)}
</Button>
<Button
className="rounded-0"
onClick={commitGlobalStagedTags}
block
>
{intl.formatMessage(messages.tagsDrawerSaveButtonText)}
</Button>
</Stack>
) : (
<div className="d-flex justify-content-center">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
</div>
)}
</div>
) : !readOnly && (
<Button
variant="outline-primary"
onClick={toEditMode}
block
>
{intl.formatMessage(messages.manageTagsButton)}
</Button>
)}
</div>
);
};
interface ContentTagsDrawerProps {
id?: string;
onClose?: () => void;
variant?: 'drawer' | 'component';
readOnly?: boolean;
}
/**
* Drawer with the functionality to show and manage tags in a certain content.
* It is used both in interfaces of this MFE and in edx-platform interfaces such as iframe.
* - If you want to use it as an iframe, the component obtains the `contentId` from the url parameters.
* Functions to close the drawer are handled internally.
* TODO: We can delete this method when is no longer used on edx-platform.
* - If you want to use it as react component, you need to pass the content id and the close functions
* through the component parameters.
*/
const ContentTagsDrawer = ({
id,
onClose,
variant = 'drawer',
readOnly = false,
}: ContentTagsDrawerProps) => {
const intl = useIntl();
// TODO: We can delete 'params' when the iframe is no longer used on edx-platform
const params = useParams();
const contentId = id ?? params.contentId;
if (contentId === undefined) {
throw new Error('Error: contentId cannot be null.');
}
const context = useContentTagsDrawerContext(contentId, !readOnly);
const { blockingSheet } = useContext(ContentTagsDrawerSheetContext);
const {
showToastAfterSave,
toReadMode,
commitGlobalStagedTagsStatus,
isTaxonomyListLoaded,
isContentTaxonomyTagsLoaded,
stagedContentTags,
collapsibleStates,
toastMessage,
closeToast,
setCollapsibleToInitalState,
otherTaxonomies,
} = context;
let onCloseDrawer: () => void;
if (variant === 'drawer') {
if (onClose === undefined) {
onCloseDrawer = () => {
// "*" allows communication with any origin
window.parent.postMessage('closeManageTagsDrawer', '*');
};
} else {
onCloseDrawer = onClose;
}
}
useEffect(() => {
if (variant === 'drawer') {
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 && !blockingSheet) {
onCloseDrawer();
}
};
document.addEventListener('keydown', handleEsc);
return () => {
document.removeEventListener('keydown', handleEsc);
};
}
return () => {};
}, [blockingSheet]);
useEffect(() => {
/* istanbul ignore next */
if (commitGlobalStagedTagsStatus === 'success') {
showToastAfterSave();
toReadMode();
}
}, [commitGlobalStagedTagsStatus]);
// First call of the initial collapsible states
React.useEffect(() => {
setCollapsibleToInitalState();
}, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded]);
const renderFooter = () => {
if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) {
switch (variant) {
case 'drawer':
return <ContentTagsDrawerVariantFooter onClose={onCloseDrawer} readOnly={readOnly} />;
case 'component':
return <ContentTagsComponentVariantFooter readOnly={readOnly} />;
default:
return null;
}
}
return null;
};
return (
<ContentTagsDrawerContext.Provider value={context}>
<div
id="content-tags-drawer"
className={classNames(
'mt-1 tags-drawer d-flex flex-column justify-content-between pt-3',
{
'min-vh-100': variant === 'drawer',
},
)}
>
<Container
size="xl"
className={classNames(
{
'p-0': variant === 'component',
},
)}
>
{variant === 'drawer' && (
<ContentTagsDrawerTitle />
)}
<Container
className={classNames(
{
'p-0': variant === 'component',
},
)}
>
{variant === 'drawer' && (
<p className="h4 text-gray-500 font-weight-bold">
{intl.formatMessage(messages.headerSubtitle)}
</p>
)}
<TaxonomyList contentId={contentId} />
{otherTaxonomies.length !== 0 && (
<div>
<p className="h4 text-gray-500 font-weight-bold">
{intl.formatMessage(messages.otherTagsHeader)}
</p>
<p className="other-description text-gray-500">
{intl.formatMessage(messages.otherTagsDescription)}
</p>
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded && (
otherTaxonomies.map((data) => (
<div key={data.id}>
<ContentTagsCollapsible
contentId={contentId}
taxonomyAndTagsData={data}
stagedContentTags={stagedContentTags[data.id] || []}
collapsibleState={collapsibleStates[data.id] || false}
/>
<hr />
</div>
))
)}
</div>
)}
</Container>
</Container>
{renderFooter()}
{/* istanbul ignore next */
toastMessage && (
<Toast
show
onClose={closeToast}
>
{toastMessage}
</Toast>
)
}
</div>
</ContentTagsDrawerContext.Provider>
);
};
export default ContentTagsDrawer;

View File

@@ -15,7 +15,6 @@ import { ContentTagsDrawerSheetContext } from './common/context';
/**
* Handles the context and all the underlying logic for the ContentTagsDrawer component
* @param {string} contentId
* @param {boolean} canTagObject
* @returns {{
* stagedContentTags: Record<number, StagedTagData[]>,
* addStagedContentTag: (taxonomyId: number, addedTag: StagedTagData) => void,
@@ -47,7 +46,7 @@ import { ContentTagsDrawerSheetContext } from './common/context';
* otherTaxonomies: TagsInTaxonomy[],
* }}
*/
const useContentTagsDrawerContext = (contentId, canTagObject) => {
const useContentTagsDrawerContext = (contentId) => {
const intl = useIntl();
const org = extractOrgFromContentId(contentId);
@@ -59,9 +58,9 @@ const useContentTagsDrawerContext = (contentId, canTagObject) => {
const [stagedContentTags, setStagedContentTags] = React.useState({});
// When a staged tags on a taxonomy is commitet then is saved on this map.
const [globalStagedContentTags, setGlobalStagedContentTags] = React.useState({});
// This stores fetched tags deleted by the user.
// This stores feched tags deleted by the user.
const [globalStagedRemovedContentTags, setGlobalStagedRemovedContentTags] = React.useState({});
// Merges fetched tags, global staged tags and global removed staged tags
// Merges feched tags, global staged tags and global removed staged tags
const [tagsByTaxonomy, setTagsByTaxonomy] = React.useState(/** @type TagsInTaxonomy[] */ ([]));
// Other taxonomies that the user doesn't have permissions
const [otherTaxonomies, setOtherTaxonomies] = React.useState(/** @type TagsInTaxonomy[] */ ([]));
@@ -80,8 +79,8 @@ const useContentTagsDrawerContext = (contentId, canTagObject) => {
} = useContentTaxonomyTagsData(contentId);
const { data: taxonomyListData, isSuccess: isTaxonomyListLoaded } = useTaxonomyList(org);
// Tags fetched from database
const { fetchedTaxonomies, fetchedOtherTaxonomies } = React.useMemo(() => {
// Tags feched from database
const { fechedTaxonomies, fechedOtherTaxonomies } = React.useMemo(() => {
const sortTaxonomies = (taxonomiesList) => {
const taxonomiesWithData = taxonomiesList.filter(
(t) => t.contentTags.length !== 0,
@@ -116,7 +115,6 @@ const useContentTagsDrawerContext = (contentId, canTagObject) => {
// Initialize list of content tags in taxonomies to populate
const taxonomiesList = taxonomyListData.results.map((taxonomy) => ({
...taxonomy,
canTagObject: taxonomy.canTagObject && canTagObject,
contentTags: /** @type {ContentTagData[]} */([]),
}));
@@ -151,13 +149,13 @@ const useContentTagsDrawerContext = (contentId, canTagObject) => {
);
return {
fetchedTaxonomies: sortTaxonomies(filteredTaxonomies),
fetchedOtherTaxonomies: otherTaxonomiesList,
fechedTaxonomies: sortTaxonomies(filteredTaxonomies),
fechedOtherTaxonomies: otherTaxonomiesList,
};
}
return {
fetchedTaxonomies: [],
fetchedOtherTaxonomies: [],
fechedTaxonomies: [],
fechedOtherTaxonomies: [],
};
}, [taxonomyListData, contentTaxonomyTagsData]);
@@ -232,28 +230,28 @@ const useContentTagsDrawerContext = (contentId, canTagObject) => {
const openAllCollapsible = React.useCallback(() => {
const updatedState = {};
fetchedTaxonomies.forEach((taxonomy) => {
fechedTaxonomies.forEach((taxonomy) => {
updatedState[taxonomy.id] = true;
});
fetchedOtherTaxonomies.forEach((taxonomy) => {
fechedOtherTaxonomies.forEach((taxonomy) => {
updatedState[taxonomy.id] = true;
});
setColapsibleStates(updatedState);
}, [fetchedTaxonomies, setColapsibleStates]);
}, [fechedTaxonomies, setColapsibleStates]);
// Set initial state of collapsible based on content tags
const setCollapsibleToInitalState = React.useCallback(() => {
const updatedState = {};
fetchedTaxonomies.forEach((taxonomy) => {
fechedTaxonomies.forEach((taxonomy) => {
// Taxonomy with content tags must be open
updatedState[taxonomy.id] = taxonomy.contentTags.length !== 0;
});
fetchedOtherTaxonomies.forEach((taxonomy) => {
fechedOtherTaxonomies.forEach((taxonomy) => {
// Taxonomy with content tags must be open
updatedState[taxonomy.id] = taxonomy.contentTags.length !== 0;
});
setColapsibleStates(updatedState);
}, [fetchedTaxonomies, setColapsibleStates]);
}, [fechedTaxonomies, setColapsibleStates]);
// Changes the drawer mode to edit
const toEditMode = React.useCallback(() => {
@@ -333,7 +331,7 @@ const useContentTagsDrawerContext = (contentId, canTagObject) => {
const closeToast = React.useCallback(() => setToastMessage(undefined), [setToastMessage]);
let contentName = '';
if (isContentDataLoaded && contentData) {
if (isContentDataLoaded) {
if ('displayName' in contentData) {
contentName = contentData.displayName;
} else {
@@ -341,14 +339,14 @@ const useContentTagsDrawerContext = (contentId, canTagObject) => {
}
}
// Updates `tagsByTaxonomy` merged fetched tags, global staged tags
// Updates `tagsByTaxonomy` merged feched tags, global staged tags
// and global removed staged tags.
React.useEffect(() => {
const mergedTags = cloneDeep(fetchedTaxonomies).reduce((acc, obj) => (
const mergedTags = cloneDeep(fechedTaxonomies).reduce((acc, obj) => (
{ ...acc, [obj.id]: obj }
), {});
const mergedOtherTaxonomies = cloneDeep(fetchedOtherTaxonomies).reduce((acc, obj) => (
const mergedOtherTaxonomies = cloneDeep(fechedOtherTaxonomies).reduce((acc, obj) => (
{ ...acc, [obj.id]: obj }
), {});
@@ -357,10 +355,10 @@ const useContentTagsDrawerContext = (contentId, canTagObject) => {
// TODO test this
// Filter out applied tags that should become implicit because a child tag was committed
const stagedLineages = globalStagedContentTags[taxonomyId].map((t) => t.lineage.slice(0, -1)).flat();
const fetchedTags = mergedTags[taxonomyId].contentTags.filter((t) => !stagedLineages.includes(t.value));
const fechedTags = mergedTags[taxonomyId].contentTags.filter((t) => !stagedLineages.includes(t.value));
mergedTags[taxonomyId].contentTags = [
...fetchedTags,
...fechedTags,
...globalStagedContentTags[taxonomyId],
];
}
@@ -379,8 +377,8 @@ const useContentTagsDrawerContext = (contentId, canTagObject) => {
});
// It is constructed this way to maintain the order
// of the list `fetchedTaxonomies`
const mergedTagsArray = fetchedTaxonomies.map(obj => mergedTags[obj.id]);
// of the list `fechedTaxonomies`
const mergedTagsArray = fechedTaxonomies.map(obj => mergedTags[obj.id]);
setTagsByTaxonomy(mergedTagsArray);
setOtherTaxonomies(Object.values(mergedOtherTaxonomies));
@@ -410,8 +408,8 @@ const useContentTagsDrawerContext = (contentId, canTagObject) => {
}
}
}, [
fetchedTaxonomies,
fetchedOtherTaxonomies,
fechedTaxonomies,
fechedOtherTaxonomies,
globalStagedContentTags,
globalStagedRemovedContentTags,
]);

View File

@@ -12,10 +12,6 @@ const ContentTagsDrawerSheet = ({ id, onClose, showSheet }) => {
blockingSheet, setBlockingSheet,
}), [blockingSheet, setBlockingSheet]);
// ContentTagsDrawerSheet is only used when editing Courses/Course Units,
// so we assume it's ok to edit the object tags too.
const readOnly = false;
return (
<ContentTagsDrawerSheetContext.Provider value={context}>
<Sheet
@@ -27,7 +23,6 @@ const ContentTagsDrawerSheet = ({ id, onClose, showSheet }) => {
<ContentTagsDrawer
id={id}
onClose={onClose}
readOnly={readOnly}
/>
</Sheet>
</ContentTagsDrawerSheetContext.Provider>

View File

@@ -5,14 +5,14 @@ import {
Spinner,
Button,
} from '@openedx/paragon';
import { SelectableBox } from '@edx/frontend-lib-content-components';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { ArrowDropDown, ArrowDropUp, Add } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import SelectableBox from '../editors/sharedComponents/SelectableBox';
import { useTaxonomyTagsData } from './data/apiHooks';
import messages from './messages';
import { useTaxonomyTagsData } from './data/apiHooks';
const HighlightedText = ({ text, highlight }) => {
if (!highlight) {
return <span>{text}</span>;
@@ -309,7 +309,7 @@ const ContentTagsDropDownSelector = ({
? (
<div>
<Button
tabIndex={0}
tabIndex="0"
variant="tertiary"
iconBefore={Add}
onClick={loadMoreTags}

View File

@@ -72,16 +72,10 @@ export async function getContentTaxonomyTagsCount(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 | null>}
* @returns {Promise<import("./types.mjs").ContentData>}
*/
export async function getContentData(contentId) {
let url;
if (contentId.startsWith('lib-collection:')) {
// This type of usage_key is not used to obtain collections
// is only used in tagging.
return null;
}
if (contentId.startsWith('lb:')) {
url = getLibraryContentDataApiUrl(contentId);
} else if (contentId.startsWith('course-v1:')) {

View File

@@ -1,378 +0,0 @@
import * as api from './api';
import * as taxonomyApi from '../../taxonomy/data/api';
import { languageExportId } from '../utils';
/**
* Mock for `getContentTaxonomyTagsData()`
*/
export async function mockContentTaxonomyTagsData(contentId: string): Promise<any> {
const thisMock = mockContentTaxonomyTagsData;
switch (contentId) {
case thisMock.stagedTagsId: return thisMock.stagedTags;
case thisMock.otherTagsId: return thisMock.otherTags;
case thisMock.languageWithTagsId: return thisMock.languageWithTags;
case thisMock.languageWithoutTagsId: return thisMock.languageWithoutTags;
case thisMock.largeTagsId: return thisMock.largeTags;
case thisMock.emptyTagsId: return thisMock.emptyTags;
default: throw new Error(`No mock has been set up for contentId "${contentId}"`);
}
}
mockContentTaxonomyTagsData.stagedTagsId = 'block-v1:StagedTagsOrg+STC1+2023_1+type@vertical+block@stagedTagsId';
mockContentTaxonomyTagsData.stagedTags = {
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,
},
],
},
],
};
mockContentTaxonomyTagsData.otherTagsId = 'block-v1:StagedTagsOrg+STC1+2023_1+type@vertical+block@otherTagsId';
mockContentTaxonomyTagsData.otherTags = {
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: 1234,
canTagObject: false,
tags: [
{
value: 'Tag 3',
lineage: ['Tag 3'],
canDeleteObjecttag: true,
},
{
value: 'Tag 4',
lineage: ['Tag 4'],
canDeleteObjecttag: true,
},
],
},
],
};
mockContentTaxonomyTagsData.languageWithTagsId = 'block-v1:LanguageTagsOrg+STC1+2023_1+type@vertical+block@languageWithTagsId';
mockContentTaxonomyTagsData.languageWithTags = {
taxonomies: [
{
name: 'Languages',
taxonomyId: 1234,
exportId: languageExportId,
canTagObject: true,
tags: [
{
value: 'Tag 1',
lineage: ['Tag 1'],
canDeleteObjecttag: true,
},
],
},
{
name: 'Taxonomy 1',
taxonomyId: 12345,
canTagObject: true,
tags: [
{
value: 'Tag 1',
lineage: ['Tag 1'],
canDeleteObjecttag: true,
},
{
value: 'Tag 2',
lineage: ['Tag 2'],
canDeleteObjecttag: true,
},
],
},
],
};
mockContentTaxonomyTagsData.languageWithoutTagsId = 'block-v1:LanguageTagsOrg+STC1+2023_1+type@vertical+block@languageWithoutTagsId';
mockContentTaxonomyTagsData.languageWithoutTags = {
taxonomies: [
{
name: 'Languages',
taxonomyId: 1234,
exportId: languageExportId,
canTagObject: true,
tags: [],
},
{
name: 'Taxonomy 1',
taxonomyId: 12345,
canTagObject: true,
tags: [
{
value: 'Tag 1',
lineage: ['Tag 1'],
canDeleteObjecttag: true,
},
{
value: 'Tag 2',
lineage: ['Tag 2'],
canDeleteObjecttag: true,
},
],
},
],
};
mockContentTaxonomyTagsData.largeTagsId = 'block-v1:LargeTagsOrg+STC1+2023_1+type@vertical+block@largeTagsId';
mockContentTaxonomyTagsData.largeTags = {
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 1',
lineage: ['Tag 1'],
canDeleteObjecttag: true,
},
],
},
{
name: 'Taxonomy 3',
taxonomyId: 125,
canTagObject: true,
tags: [
{
value: 'Tag 1.1.1',
lineage: ['Tag 1', 'Tag 1.1', 'Tag 1.1.1'],
canDeleteObjecttag: true,
},
],
},
{
name: '(B) Taxonomy 4',
taxonomyId: 126,
canTagObject: true,
tags: [],
},
{
name: '(A) Taxonomy 5',
taxonomyId: 127,
canTagObject: true,
tags: [],
},
],
};
mockContentTaxonomyTagsData.emptyTagsId = 'block-v1:EmptyTagsOrg+STC1+2023_1+type@vertical+block@emptyTagsId';
mockContentTaxonomyTagsData.emptyTags = {
taxonomies: [],
};
mockContentTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getContentTaxonomyTagsData').mockImplementation(mockContentTaxonomyTagsData);
/**
* Mock for `getTaxonomyListData()`
*/
export async function mockTaxonomyListData(org: string): Promise<any> {
const thisMock = mockTaxonomyListData;
switch (org) {
case thisMock.stagedTagsOrg: return thisMock.stagedTags;
case thisMock.languageTagsOrg: return thisMock.languageTags;
case thisMock.largeTagsOrg: return thisMock.largeTags;
case thisMock.emptyTagsOrg: return thisMock.emptyTags;
default: throw new Error(`No mock has been set up for org "${org}"`);
}
}
mockTaxonomyListData.stagedTagsOrg = 'StagedTagsOrg';
mockTaxonomyListData.stagedTags = {
results: [
{
id: 123,
name: 'Taxonomy 1',
description: 'This is a description 1',
canTagObject: true,
},
],
};
mockTaxonomyListData.languageTagsOrg = 'LanguageTagsOrg';
mockTaxonomyListData.languageTags = {
results: [
{
id: 1234,
name: 'Languages',
description: 'This is a description 1',
exportId: languageExportId,
canTagObject: true,
},
{
id: 12345,
name: 'Taxonomy 1',
description: 'This is a description 2',
canTagObject: true,
},
],
};
mockTaxonomyListData.largeTagsOrg = 'LargeTagsOrg';
mockTaxonomyListData.largeTags = {
results: [
{
id: 123,
name: 'Taxonomy 1',
description: 'This is a description 1',
canTagObject: true,
},
{
id: 124,
name: 'Taxonomy 2',
description: 'This is a description 2',
canTagObject: true,
},
{
id: 125,
name: 'Taxonomy 3',
description: 'This is a description 3',
canTagObject: true,
},
{
id: 127,
name: '(A) Taxonomy 5',
description: 'This is a description 5',
canTagObject: true,
},
{
id: 126,
name: '(B) Taxonomy 4',
description: 'This is a description 4',
canTagObject: true,
},
],
};
mockTaxonomyListData.emptyTagsOrg = 'EmptyTagsOrg';
mockTaxonomyListData.emptyTags = {
results: [],
};
mockTaxonomyListData.applyMock = () => jest.spyOn(taxonomyApi, 'getTaxonomyListData').mockImplementation(mockTaxonomyListData);
/**
* Mock for `getTaxonomyTagsData()`
*/
export async function mockTaxonomyTagsData(taxonomyId: number): Promise<any> {
const thisMock = mockTaxonomyTagsData;
switch (taxonomyId) {
case thisMock.stagedTagsTaxonomy: return thisMock.stagedTags;
case thisMock.languageTagsTaxonomy: return thisMock.languageTags;
default: throw new Error(`No mock has been set up for taxonomyId "${taxonomyId}"`);
}
}
mockTaxonomyTagsData.stagedTagsTaxonomy = 123;
mockTaxonomyTagsData.stagedTags = {
count: 3,
currentPage: 1,
next: null,
numPages: 1,
previous: null,
start: 1,
results: [
{
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,
},
],
};
mockTaxonomyTagsData.languageTagsTaxonomy = 1234;
mockTaxonomyTagsData.languageTags = {
count: 1,
currentPage: 1,
next: null,
numPages: 1,
previous: null,
start: 1,
results: [{
value: 'Tag 1',
externalId: null,
childCount: 0,
depth: 0,
parentValue: null,
id: 12345,
subTagsUrl: null,
canChangeTag: false,
canDeleteTag: false,
}],
};
mockTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getTaxonomyTagsData').mockImplementation(mockTaxonomyTagsData);
/**
* Mock for `getContentData()`
*/
export async function mockContentData(): Promise<any> {
return mockContentData.data;
}
mockContentData.data = {
displayName: 'Unit 1',
};
mockContentData.applyMock = () => jest.spyOn(api, 'getContentData').mockImplementation(mockContentData);

View File

@@ -14,8 +14,6 @@ import {
updateContentTaxonomyTags,
getContentTaxonomyTagsCount,
} from './api';
import { libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
import { getLibraryId } from '../../generic/key-utils';
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagData} TagData */
@@ -148,14 +146,6 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
contentPattern = contentId.replace(/\+type@.*$/, '*');
}
queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] });
if (contentId.startsWith('lb:') || contentId.startsWith('lib-collection:')) {
// Obtain library id from contentId
const libraryId = getLibraryId(contentId);
// Invalidate component metadata to update tags count
queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId));
// Invalidate content search to update tags count
queryClient.invalidateQueries(['content_search'], { predicate: (query) => libraryQueryPredicate(query, libraryId) });
}
},
onSuccess: /* istanbul ignore next */ () => {
/* istanbul ignore next */

View File

@@ -69,7 +69,7 @@ const messages = defineMessages({
defaultMessage: 'Add a tag',
},
collapsibleNoTagsAddedText: {
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.no-tags-added-text',
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.placeholder-text',
defaultMessage: 'No tags added yet.',
},
collapsibleAddStagedTagsButtonText: {

View File

@@ -15,7 +15,7 @@ const TagsSidebarHeader = () => {
const {
data: contentTagsCount,
isSuccess: isContentTagsCountLoaded,
} = useContentTagsCount(contentId);
} = useContentTagsCount(contentId || '');
return (
<Stack

View File

@@ -79,9 +79,10 @@ const ChecklistItemComment = ({
<ul className="assignment-list">
{gradedAssignmentsOutsideDateRange.map(assignment => (
<li className="assignment-list-item" key={assignment.id}>
<Hyperlink destination={`${outlineUrl}#${assignment.id}`}>
{assignment.displayName}
</Hyperlink>
<Hyperlink
content={assignment.displayName}
destination={`${outlineUrl}#${assignment.id}`}
/>
</li>
))}
</ul>

View File

@@ -2,30 +2,30 @@ import * as healthValidators from './courseChecklistValidators';
const getValidatedValue = (data, id) => {
switch (id) {
case 'welcomeMessage':
return healthValidators.hasWelcomeMessage(data.updates);
case 'gradingPolicy':
return healthValidators.hasGradingPolicy(data.grades);
case 'certificate':
return healthValidators.hasCertificate(data.certificates);
case 'courseDates':
return healthValidators.hasDates(data.dates);
case 'assignmentDeadlines':
return healthValidators.hasAssignmentDeadlines(data.assignments, data.dates);
case 'videoDuration':
return healthValidators.hasShortVideoDuration(data.videos);
case 'mobileFriendlyVideo':
return healthValidators.hasMobileFriendlyVideos(data.videos);
case 'diverseSequences':
return healthValidators.hasDiverseSequences(data.subsections);
case 'weeklyHighlights':
return healthValidators.hasWeeklyHighlights(data.sections);
case 'unitDepth':
return healthValidators.hasShortUnitDepth(data.units);
case 'proctoringEmail':
return healthValidators.hasProctoringEscalationEmail(data.proctoring);
default:
throw new Error(`Unknown validator ${id}.`);
case 'welcomeMessage':
return healthValidators.hasWelcomeMessage(data.updates);
case 'gradingPolicy':
return healthValidators.hasGradingPolicy(data.grades);
case 'certificate':
return healthValidators.hasCertificate(data.certificates);
case 'courseDates':
return healthValidators.hasDates(data.dates);
case 'assignmentDeadlines':
return healthValidators.hasAssignmentDeadlines(data.assignments, data.dates);
case 'videoDuration':
return healthValidators.hasShortVideoDuration(data.videos);
case 'mobileFriendlyVideo':
return healthValidators.hasMobileFriendlyVideos(data.videos);
case 'diverseSequences':
return healthValidators.hasDiverseSequences(data.subsections);
case 'weeklyHighlights':
return healthValidators.hasWeeklyHighlights(data.sections);
case 'unitDepth':
return healthValidators.hasShortUnitDepth(data.units);
case 'proctoringEmail':
return healthValidators.hasProctoringEscalationEmail(data.proctoring);
default:
throw new Error(`Unknown validator ${id}.`);
}
};

View File

@@ -125,8 +125,7 @@ const CourseOutline = ({ courseId }) => {
const [toastMessage, setToastMessage] = useState(/** @type{null|string} */ (null));
useEffect(() => {
// Wait for the course data to load before exporting tags.
if (courseId && courseName && location.hash === '#export-tags') {
if (location.hash === '#export-tags') {
setToastMessage(intl.formatMessage(messages.exportTagsCreatingToastMessage));
getTagsExportFile(courseId, courseName).then(() => {
setToastMessage(intl.formatMessage(messages.exportTagsSuccessToastMessage));
@@ -137,7 +136,7 @@ const CourseOutline = ({ courseId }) => {
// Delete `#export-tags` from location
window.location.href = '#';
}
}, [location, courseId, courseName]);
}, [location]);
const [sections, setSections] = useState(sectionsList);
@@ -460,7 +459,6 @@ const CourseOutline = ({ courseId }) => {
onConfigureSubmit={handleConfigureItemSubmit}
currentItemData={currentItemData}
enableProctoredExams={enableProctoredExams}
isSelfPaced={statusBarData.isSelfPaced}
/>
<DeleteModal
category={deleteCategory}

View File

@@ -226,7 +226,7 @@ describe('<CourseOutline />', () => {
});
it('check video sharing option shows error on failure', async () => {
render(<RootWrapper />);
const { findByLabelText, queryByRole } = render(<RootWrapper />);
axiosMock
.onPost(getCourseBlockApiUrl(courseId), {
@@ -235,7 +235,7 @@ describe('<CourseOutline />', () => {
},
})
.reply(500);
const optionDropdown = await screen.findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage);
const optionDropdown = await findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage);
await act(
async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }),
);
@@ -247,10 +247,8 @@ describe('<CourseOutline />', () => {
},
}));
const alertElements = screen.queryAllByRole('alert');
expect(alertElements.find(
(el) => el.classList.contains('alert-content'),
)).toHaveTextContent(
const alertElement = queryByRole('alert');
expect(alertElement).toHaveTextContent(
pageAlertMessages.alertFailedGeneric.defaultMessage,
);
});
@@ -513,10 +511,9 @@ describe('<CourseOutline />', () => {
notificationDismissUrl: '/some/url',
});
render(<RootWrapper />);
const alert = await screen.findByText(pageAlertMessages.configurationErrorTitle.defaultMessage);
expect(alert).toBeInTheDocument();
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
const { findByRole } = render(<RootWrapper />);
expect(await findByRole('alert')).toBeInTheDocument();
const dismissBtn = await findByRole('button', { name: 'Dismiss' });
axiosMock
.onDelete('/some/url')
.reply(204);
@@ -1412,7 +1409,6 @@ describe('<CourseOutline />', () => {
publish: 'republish',
metadata: {
visible_to_staff_only: isVisibleToStaffOnly,
discussion_enabled: false,
group_access: newGroupAccess,
},
})
@@ -1431,7 +1427,6 @@ describe('<CourseOutline />', () => {
// after configuraiton response
unit.visibilityState = 'staff_only';
unit.discussion_enabled = false;
unit.userPartitionInfo = {
selectablePartitions: [
{
@@ -1474,11 +1469,6 @@ describe('<CourseOutline />', () => {
)).toBeInTheDocument();
let visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
await act(async () => fireEvent.click(visibilityCheckbox));
let discussionCheckbox = await within(configureModal).findByLabelText(
configureModalMessages.discussionEnabledCheckbox.defaultMessage,
);
expect(discussionCheckbox).toBeChecked();
await act(async () => fireEvent.click(discussionCheckbox));
let groupeType = await within(configureModal).findByTestId('group-type-select');
fireEvent.change(groupeType, { target: { value: '0' } });
@@ -1495,10 +1485,6 @@ describe('<CourseOutline />', () => {
configureModal = await findByTestId('configure-modal');
visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
expect(visibilityCheckbox).toBeChecked();
discussionCheckbox = await within(configureModal).findByLabelText(
configureModalMessages.discussionEnabledCheckbox.defaultMessage,
);
expect(discussionCheckbox).not.toBeChecked();
groupeType = await within(configureModal).findByTestId('group-type-select');
expect(groupeType).toHaveValue('0');
@@ -2163,10 +2149,10 @@ describe('<CourseOutline />', () => {
});
it('check whether unit copy & paste option works correctly', async () => {
render(<RootWrapper />);
const { findAllByTestId, queryByTestId, findAllByRole } = render(<RootWrapper />);
// get first section -> first subsection -> first unit element
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [sectionElement] = await screen.findAllByTestId('section-card');
const [sectionElement] = await findAllByTestId('section-card');
const [subsection] = section.childInfo.children;
axiosMock
.onGet(getXBlockApiUrl(section.id))
@@ -2205,7 +2191,7 @@ describe('<CourseOutline />', () => {
await act(async () => fireEvent.mouseOver(clipboardLabel));
// find clipboard content popover link
const popoverContent = screen.queryByTestId('popover-content');
const popoverContent = queryByTestId('popover-content');
expect(popoverContent.tagName).toBe('A');
expect(popoverContent).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${unit.studioUrl}`);
@@ -2236,10 +2222,8 @@ describe('<CourseOutline />', () => {
errorFiles: ['error.css'],
});
let alerts = await screen.findAllByRole('alert');
// Exclude processing notification toast
alerts = alerts.filter((el) => !el.classList.contains('toast-container'));
// 3 alerts should be present
const alerts = await findAllByRole('alert');
expect(alerts.length).toEqual(3);
// check alerts for errorFiles

View File

@@ -142,7 +142,7 @@ const CardHeader = ({
{(isVertical || isSequential) && (
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge} />
)}
{ getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && !!contentTagCount && (
{ getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && contentTagCount > 0 && (
<TagCount count={contentTagCount} onClick={openManageTagsDrawer} />
)}
<Dropdown data-testid={`${namePrefix}-card-header__menu`} onClick={onClickMenuButton}>

View File

@@ -58,7 +58,7 @@ const messages = defineMessages({
defaultMessage: 'Delete',
},
menuCopy: {
id: 'course-authoring.course-outline.card.menu.copy',
id: 'course-authoring.course-outline.card.menu.delete',
defaultMessage: 'Copy to clipboard',
},
menuProctoringLinkText: {

View File

@@ -311,7 +311,7 @@ export async function configureCourseSubsection(
* @param {object} groupAccess
* @returns {Promise<Object>}
*/
export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAccess, discussionEnabled) {
export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAccess) {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseItemApiUrl(unitId), {
publish: 'republish',
@@ -319,7 +319,6 @@ export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAcc
// The backend expects metadata.visible_to_staff_only to either true or null
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
group_access: groupAccess,
discussion_enabled: discussionEnabled,
},
});

View File

@@ -57,10 +57,7 @@ import {
const getErrorDetails = (error, dismissible = true) => {
const errorInfo = { dismissible };
if (error.response?.data) {
const { data } = error.response;
if ((typeof data === 'string' && !data.includes('</html>')) || typeof data === 'object') {
errorInfo.data = JSON.stringify(data);
}
errorInfo.data = JSON.stringify(error.response.data);
errorInfo.status = error.response.status;
errorInfo.type = API_ERROR_TYPES.serverError;
} else if (error.request) {
@@ -337,11 +334,11 @@ export function configureCourseSubsectionQuery(
};
}
export function configureCourseUnitQuery(itemId, sectionId, isVisibleToStaffOnly, groupAccess, discussionEnabled) {
export function configureCourseUnitQuery(itemId, sectionId, isVisibleToStaffOnly, groupAccess) {
return async (dispatch) => {
dispatch(configureCourseItemQuery(
sectionId,
async () => configureCourseUnit(itemId, isVisibleToStaffOnly, groupAccess, discussionEnabled),
async () => configureCourseUnit(itemId, isVisibleToStaffOnly, groupAccess),
));
};
}

View File

@@ -3,11 +3,15 @@ import { render, fireEvent } 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 initializeStore from '../../store';
import EnableHighlightsModal from './EnableHighlightsModal';
import messages from './messages';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
const mockPathname = '/foo-bar';
@@ -52,6 +56,7 @@ describe('<EnableHighlightsModal />', () => {
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('renders EnableHighlightsModal component correctly', () => {

View File

@@ -38,7 +38,6 @@ const HighlightsModal = ({
onClose={onClose}
hasCloseButton
isFullscreenOnMobile
isOverflowVisible={false}
>
<ModalDialog.Header className="highlights-modal__header">
<ModalDialog.Title>

View File

@@ -5,12 +5,16 @@ import {
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../store';
import HighlightsModal from './HighlightsModal';
import messages from './messages';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
const mockPathname = '/foo-bar';
@@ -64,6 +68,7 @@ describe('<HighlightsModal />', () => {
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
useSelector.mockReturnValue(currentItemMock);
});

View File

@@ -183,17 +183,17 @@ const useCourseOutline = ({ courseId }) => {
const handleConfigureItemSubmit = (...arg) => {
switch (currentItem.category) {
case COURSE_BLOCK_NAMES.chapter.id:
dispatch(configureCourseSectionQuery(currentSection.id, ...arg));
break;
case COURSE_BLOCK_NAMES.sequential.id:
dispatch(configureCourseSubsectionQuery(currentItem.id, currentSection.id, ...arg));
break;
case COURSE_BLOCK_NAMES.vertical.id:
dispatch(configureCourseUnitQuery(currentItem.id, currentSection.id, ...arg));
break;
default:
return;
case COURSE_BLOCK_NAMES.chapter.id:
dispatch(configureCourseSectionQuery(currentSection.id, ...arg));
break;
case COURSE_BLOCK_NAMES.sequential.id:
dispatch(configureCourseSubsectionQuery(currentItem.id, currentSection.id, ...arg));
break;
case COURSE_BLOCK_NAMES.vertical.id:
dispatch(configureCourseUnitQuery(currentItem.id, currentSection.id, ...arg));
break;
default:
return;
}
handleConfigureModalClose();
};
@@ -204,21 +204,21 @@ const useCourseOutline = ({ courseId }) => {
const handleDeleteItemSubmit = () => {
switch (currentItem.category) {
case COURSE_BLOCK_NAMES.chapter.id:
dispatch(deleteCourseSectionQuery(currentItem.id));
break;
case COURSE_BLOCK_NAMES.sequential.id:
dispatch(deleteCourseSubsectionQuery(currentItem.id, currentSection.id));
break;
case COURSE_BLOCK_NAMES.vertical.id:
dispatch(deleteCourseUnitQuery(
currentItem.id,
currentSubsection.id,
currentSection.id,
));
break;
default:
return;
case COURSE_BLOCK_NAMES.chapter.id:
dispatch(deleteCourseSectionQuery(currentItem.id));
break;
case COURSE_BLOCK_NAMES.sequential.id:
dispatch(deleteCourseSubsectionQuery(currentItem.id, currentSection.id));
break;
case COURSE_BLOCK_NAMES.vertical.id:
dispatch(deleteCourseUnitQuery(
currentItem.id,
currentSubsection.id,
currentSection.id,
));
break;
default:
return;
}
closeDeleteModal();
};

View File

@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { uniqBy } from 'lodash';
import { getConfig } from '@edx/frontend-platform';
import { useDispatch, useSelector } from 'react-redux';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { ErrorAlert } from '@edx/frontend-lib-content-components';
import {
Campaign as CampaignIcon,
InfoOutline as InfoOutlineIcon,
@@ -15,7 +15,6 @@ import {
} from '@openedx/paragon';
import { Link } from 'react-router-dom';
import ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert';
import { RequestStatus } from '../../data/constants';
import AlertMessage from '../../generic/alert-message';
import AlertProctoringError from '../../generic/AlertProctoringError';
@@ -42,11 +41,8 @@ const PageAlerts = ({
const intl = useIntl();
const dispatch = useDispatch();
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
const discussionAlertDismissKey = `discussionAlertDismissed-${courseId}`;
const [showConfigAlert, setShowConfigAlert] = useState(true);
const [showDiscussionAlert, setShowDiscussionAlert] = useState(
localStorage.getItem(discussionAlertDismissKey) === null,
);
const [showDiscussionAlert, setShowDiscussionAlert] = useState(true);
const { newFiles, conflictingFiles, errorFiles } = useSelector(getPasteFileNotices);
const getAssetsUrl = () => {
@@ -87,7 +83,6 @@ const PageAlerts = ({
const onDismiss = () => {
setShowDiscussionAlert(false);
localStorage.setItem(discussionAlertDismissKey, 'true');
};
return (
@@ -341,30 +336,31 @@ const PageAlerts = ({
};
const renderApiErrors = () => {
let errorList = Object.entries(errors).filter(obj => obj[1] !== null).map(([k, v]) => {
const errorList = Object.entries(errors).filter(obj => obj[1] !== null).map(([k, v]) => {
switch (v.type) {
case API_ERROR_TYPES.serverError:
return {
key: k,
desc: v.data || intl.formatMessage(messages.serverErrorAlertBody),
title: intl.formatMessage(messages.serverErrorAlert),
dismissible: v.dismissible,
};
case API_ERROR_TYPES.networkError:
return {
key: k,
title: intl.formatMessage(messages.networkErrorAlert),
dismissible: v.dismissible,
};
default:
return {
key: k,
title: v.data,
dismissible: v.dismissible,
};
case API_ERROR_TYPES.serverError:
return {
key: k,
desc: v.data,
title: intl.formatMessage(messages.serverErrorAlert, {
status: v.status,
}),
dismissible: v.dismissible,
};
case API_ERROR_TYPES.networkError:
return {
key: k,
title: intl.formatMessage(messages.networkErrorAlert),
dismissible: v.dismissible,
};
default:
return {
key: k,
desc: v.data,
dismissible: v.dismissible,
};
}
});
errorList = uniqBy(errorList, 'title');
if (!errorList?.length) {
return null;
}
@@ -377,7 +373,10 @@ const PageAlerts = ({
key={msgObj.key}
dismissError={() => dispatch(dismissError(msgObj.key))}
>
<Alert.Heading>{msgObj.title}</Alert.Heading>
{msgObj.title
&& (
<Alert.Heading>{msgObj.title}</Alert.Heading>
)}
{msgObj.desc && <Truncate lines={2}>{msgObj.desc}</Truncate>}
</ErrorAlert>
) : (
@@ -386,7 +385,10 @@ const PageAlerts = ({
icon={ErrorIcon}
key={msgObj.key}
>
<Alert.Heading>{msgObj.title}</Alert.Heading>
{msgObj.title
&& (
<Alert.Heading>{msgObj.title}</Alert.Heading>
)}
{msgObj.desc && <Truncate lines={2}>{msgObj.desc}</Truncate>}
</Alert>
)

View File

@@ -1,12 +1,6 @@
import React from 'react';
import { useSelector } from 'react-redux';
import {
act,
render,
fireEvent,
screen,
waitFor,
} from '@testing-library/react';
import { act, render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp, getConfig } from '@edx/frontend-platform';
@@ -90,7 +84,7 @@ describe('<PageAlerts />', () => {
});
it('renders discussion alerts', async () => {
renderComponent({
const { queryByText } = renderComponent({
...pageAlertsData,
discussionsSettings: {
providerType: 'openedx',
@@ -99,21 +93,14 @@ describe('<PageAlerts />', () => {
discussionsIncontextLearnmoreUrl: 'some-learn-more-url',
});
expect(screen.queryByText(messages.discussionNotificationText.defaultMessage)).toBeInTheDocument();
const learnMoreBtn = screen.queryByText(messages.discussionNotificationLearnMore.defaultMessage);
expect(queryByText(messages.discussionNotificationText.defaultMessage)).toBeInTheDocument();
const learnMoreBtn = queryByText(messages.discussionNotificationLearnMore.defaultMessage);
expect(learnMoreBtn).toBeInTheDocument();
expect(learnMoreBtn).toHaveAttribute('href', 'some-learn-more-url');
const dismissBtn = screen.queryByText('Dismiss');
fireEvent.click(dismissBtn);
const discussionAlertDismissKey = `discussionAlertDismissed-${pageAlertsData.courseId}`;
expect(localStorage.getItem(discussionAlertDismissKey)).toBe('true');
await waitFor(() => {
const feedbackLink = screen.queryByText(messages.discussionNotificationFeedback.defaultMessage);
expect(feedbackLink).toBeInTheDocument();
expect(feedbackLink).toHaveAttribute('href', 'some-feedback-url');
});
const feedbackLink = queryByText(messages.discussionNotificationFeedback.defaultMessage);
expect(feedbackLink).toBeInTheDocument();
expect(feedbackLink).toHaveAttribute('href', 'some-feedback-url');
});
it('renders deprecation warning alerts', async () => {

View File

@@ -22,7 +22,7 @@ const messages = defineMessages({
description: 'Learn more link in upgraded discussion notification alert',
},
discussionNotificationFeedback: {
id: 'course-authoring.course-outline.page-alerts.discussionNotificationFeedback',
id: 'course-authoring.course-outline.page-alerts.discussionNotificationLearnMore',
defaultMessage: 'Share feedback',
description: 'Share feedback link in upgraded discussion notification alert',
},
@@ -108,12 +108,7 @@ const messages = defineMessages({
},
serverErrorAlert: {
id: 'course-authoring.course-outline.page-alert.server-error.title',
defaultMessage: 'The Studio servers encountered an error',
description: 'Generic server error alert title.',
},
serverErrorAlertBody: {
id: 'course-authoring.course-outline.page-alert.server-error.body',
defaultMessage: ' An error occurred in Studio and the page could not be loaded. Please try again in a few moments. We\'ve logged the error and our staff is currently working to resolve this error as soon as possible.',
defaultMessage: 'Request failed with status: {status}',
description: 'Generic server error alert title.',
},
networkErrorAlert: {

View File

@@ -30,7 +30,6 @@ const PublishModal = ({
onClose={onClose}
hasCloseButton
isFullscreenOnMobile
isOverflowVisible={false}
>
<ModalDialog.Header className="publish-modal__header">
<ModalDialog.Title>

View File

@@ -3,12 +3,16 @@ import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../store';
import PublishModal from './PublishModal';
import messages from './messages';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
jest.mock('react-redux', () => ({
@@ -96,6 +100,7 @@ describe('<PublishModal />', () => {
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
useSelector.mockReturnValue(currentItemMock);
});

View File

@@ -6,11 +6,15 @@ import {
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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import initializeStore from '../../store';
import SectionCard from './SectionCard';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
const mockPathname = '/foo-bar';
@@ -121,6 +125,7 @@ describe('<SectionCard />', () => {
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('render SectionCard component correctly', () => {

View File

@@ -56,7 +56,7 @@ const messages = defineMessages({
defaultMessage: 'Video Sharing',
},
videoSharingLink: {
id: 'course-authoring.course-outline.status-bar.video-sharing.link',
id: 'course-authoring.course-outline.status-bar.video-sharing.title',
defaultMessage: 'Learn more',
},
videoSharingPerVideoText: {

View File

@@ -5,12 +5,16 @@ import {
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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import initializeStore from '../../store';
import SubsectionCard from './SubsectionCard';
import cardHeaderMessages from '../card-header/messages';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
const mockPathname = '/foo-bar';
@@ -117,6 +121,7 @@ describe('<SubsectionCard />', () => {
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('render SubsectionCard component correctly', () => {

View File

@@ -6,7 +6,7 @@ const messages = defineMessages({
defaultMessage: 'New unit',
},
pasteButton: {
id: 'course-authoring.course-outline.subsection.button.paste-unit',
id: 'course-authoring.course-outline.subsection.button.new-unit',
defaultMessage: 'Paste unit',
},
});

View File

@@ -5,12 +5,16 @@ import {
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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import initializeStore from '../../store';
import UnitCard from './UnitCard';
import cardMessages from '../card-header/messages';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
const section = {
@@ -88,6 +92,7 @@ describe('<UnitCard />', () => {
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('render UnitCard component correctly', async () => {

View File

@@ -19,20 +19,20 @@ const getItemStatus = ({
hasChanges,
}) => {
switch (true) {
case visibilityState === VisibilityTypes.STAFF_ONLY:
return ITEM_BADGE_STATUS.staffOnly;
case visibilityState === VisibilityTypes.GATED:
return ITEM_BADGE_STATUS.gated;
case visibilityState === VisibilityTypes.LIVE:
return ITEM_BADGE_STATUS.live;
case visibilityState === VisibilityTypes.UNSCHEDULED:
return ITEM_BADGE_STATUS.unscheduled;
case published && !hasChanges:
return ITEM_BADGE_STATUS.publishedNotLive;
case published && hasChanges:
return ITEM_BADGE_STATUS.unpublishedChanges;
default:
return ITEM_BADGE_STATUS.draft;
case visibilityState === VisibilityTypes.STAFF_ONLY:
return ITEM_BADGE_STATUS.staffOnly;
case visibilityState === VisibilityTypes.GATED:
return ITEM_BADGE_STATUS.gated;
case visibilityState === VisibilityTypes.LIVE:
return ITEM_BADGE_STATUS.live;
case visibilityState === VisibilityTypes.UNSCHEDULED:
return ITEM_BADGE_STATUS.unscheduled;
case published && !hasChanges:
return ITEM_BADGE_STATUS.publishedNotLive;
case published && hasChanges:
return ITEM_BADGE_STATUS.unpublishedChanges;
default:
return ITEM_BADGE_STATUS.draft;
}
};
@@ -46,41 +46,41 @@ const getItemStatus = ({
*/
const getItemStatusBadgeContent = (status, messages, intl) => {
switch (status) {
case ITEM_BADGE_STATUS.gated:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeGated),
badgeIcon: LockIcon,
};
case ITEM_BADGE_STATUS.live:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeLive),
badgeIcon: CheckCircleIcon,
};
case ITEM_BADGE_STATUS.publishedNotLive:
return {
badgeTitle: intl.formatMessage(messages.statusBadgePublishedNotLive),
badgeIcon: null,
};
case ITEM_BADGE_STATUS.staffOnly:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeStaffOnly),
badgeIcon: LockIcon,
};
case ITEM_BADGE_STATUS.unpublishedChanges:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeUnpublishedChanges),
badgeIcon: DraftIcon,
};
case ITEM_BADGE_STATUS.draft:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeDraft),
badgeIcon: DraftIcon,
};
default:
return {
badgeTitle: '',
badgeIcon: null,
};
case ITEM_BADGE_STATUS.gated:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeGated),
badgeIcon: LockIcon,
};
case ITEM_BADGE_STATUS.live:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeLive),
badgeIcon: CheckCircleIcon,
};
case ITEM_BADGE_STATUS.publishedNotLive:
return {
badgeTitle: intl.formatMessage(messages.statusBadgePublishedNotLive),
badgeIcon: null,
};
case ITEM_BADGE_STATUS.staffOnly:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeStaffOnly),
badgeIcon: LockIcon,
};
case ITEM_BADGE_STATUS.unpublishedChanges:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeUnpublishedChanges),
badgeIcon: DraftIcon,
};
case ITEM_BADGE_STATUS.draft:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeDraft),
badgeIcon: DraftIcon,
};
default:
return {
badgeTitle: '',
badgeIcon: null,
};
}
};
@@ -93,36 +93,36 @@ const getItemStatusBadgeContent = (status, messages, intl) => {
*/
const getItemStatusBorder = (status) => {
switch (status) {
case ITEM_BADGE_STATUS.live:
return {
borderLeft: '5px solid #00688D',
};
case ITEM_BADGE_STATUS.publishedNotLive:
return {
borderLeft: '5px solid #0D7D4D',
};
case ITEM_BADGE_STATUS.gated:
return {
borderLeft: '5px solid #000000',
};
case ITEM_BADGE_STATUS.staffOnly:
return {
borderLeft: '5px solid #000000',
};
case ITEM_BADGE_STATUS.unpublishedChanges:
return {
borderLeft: '5px solid #F0CC00',
};
case ITEM_BADGE_STATUS.draft:
return {
borderLeft: '5px solid #F0CC00',
};
case ITEM_BADGE_STATUS.unscheduled:
return {
borderLeft: '5px solid #ccc',
};
default:
return {};
case ITEM_BADGE_STATUS.live:
return {
borderLeft: '5px solid #00688D',
};
case ITEM_BADGE_STATUS.publishedNotLive:
return {
borderLeft: '5px solid #0D7D4D',
};
case ITEM_BADGE_STATUS.gated:
return {
borderLeft: '5px solid #000000',
};
case ITEM_BADGE_STATUS.staffOnly:
return {
borderLeft: '5px solid #000000',
};
case ITEM_BADGE_STATUS.unpublishedChanges:
return {
borderLeft: '5px solid #F0CC00',
};
case ITEM_BADGE_STATUS.draft:
return {
borderLeft: '5px solid #F0CC00',
};
case ITEM_BADGE_STATUS.unscheduled:
return {
borderLeft: '5px solid #ccc',
};
default:
return {};
}
};
@@ -195,14 +195,14 @@ const scrollToElement = (target, alignWithTop = false) => {
*/
const getVideoSharingOptionText = (id, messages, intl) => {
switch (id) {
case VIDEO_SHARING_OPTIONS.perVideo:
return intl.formatMessage(messages.videoSharingPerVideoText);
case VIDEO_SHARING_OPTIONS.allOn:
return intl.formatMessage(messages.videoSharingAllOnText);
case VIDEO_SHARING_OPTIONS.allOff:
return intl.formatMessage(messages.videoSharingAllOffText);
default:
return '';
case VIDEO_SHARING_OPTIONS.perVideo:
return intl.formatMessage(messages.videoSharingPerVideoText);
case VIDEO_SHARING_OPTIONS.allOn:
return intl.formatMessage(messages.videoSharingAllOnText);
case VIDEO_SHARING_OPTIONS.allOff:
return intl.formatMessage(messages.videoSharingAllOffText);
default:
return '';
}
};

View File

@@ -6,14 +6,14 @@
export const hasWelcomeMessage = (updates) => updates.hasUpdate;
export const hasGradingPolicy = (grades) => {
// eslint-disable-next-line @typescript-eslint/no-shadow
// eslint-disable-next-line no-shadow
const { hasGradingPolicy, sumOfWeights } = grades;
return hasGradingPolicy && parseFloat(sumOfWeights.toPrecision(2), 10) === 1.0;
};
export const hasCertificate = (certificates) => {
// eslint-disable-next-line @typescript-eslint/no-shadow
// eslint-disable-next-line no-shadow
const { isActivated, hasCertificate } = certificates;
return isActivated && hasCertificate;

View File

@@ -20,30 +20,30 @@ const getChecklistValidatedValue = (data, id) => {
} = data;
switch (id) {
case 'welcomeMessage':
return healthValidators.hasWelcomeMessage(updates);
case 'gradingPolicy':
return healthValidators.hasGradingPolicy(grades);
case 'certificate':
return healthValidators.hasCertificate(certificates);
case 'courseDates':
return healthValidators.hasDates(dates);
case 'assignmentDeadlines':
return healthValidators.hasAssignmentDeadlines(assignments, dates);
case 'videoDuration':
return healthValidators.hasShortVideoDuration(videos);
case 'mobileFriendlyVideo':
return healthValidators.hasMobileFriendlyVideos(videos);
case 'diverseSequences':
return healthValidators.hasDiverseSequences(subsections);
case 'weeklyHighlights':
return healthValidators.hasWeeklyHighlights(sections);
case 'unitDepth':
return healthValidators.hasShortUnitDepth(units);
case 'proctoringEmail':
return healthValidators.hasProctoringEscalationEmail(proctoring);
default:
throw new Error(`Unknown validator ${id}.`);
case 'welcomeMessage':
return healthValidators.hasWelcomeMessage(updates);
case 'gradingPolicy':
return healthValidators.hasGradingPolicy(grades);
case 'certificate':
return healthValidators.hasCertificate(certificates);
case 'courseDates':
return healthValidators.hasDates(dates);
case 'assignmentDeadlines':
return healthValidators.hasAssignmentDeadlines(assignments, dates);
case 'videoDuration':
return healthValidators.hasShortVideoDuration(videos);
case 'mobileFriendlyVideo':
return healthValidators.hasMobileFriendlyVideos(videos);
case 'diverseSequences':
return healthValidators.hasDiverseSequences(subsections);
case 'weeklyHighlights':
return healthValidators.hasWeeklyHighlights(sections);
case 'unitDepth':
return healthValidators.hasShortUnitDepth(units);
case 'proctoringEmail':
return healthValidators.hasProctoringEscalationEmail(proctoring);
default:
throw new Error(`Unknown validator ${id}.`);
}
};

View File

@@ -3,11 +3,15 @@ import { render } 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 initializeStore from '../../store';
import XBlockStatus from './XBlockStatus';
import messages from './messages';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
jest.mock('@edx/frontend-platform/i18n', () => ({
@@ -69,6 +73,7 @@ describe('<XBlockStatus /> for Instructor paced Section', () => {
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('render XBlockStatus with explanatoryMessage', () => {
@@ -136,6 +141,7 @@ describe('<XBlockStatus /> for self paced Section', () => {
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('renders XBlockStatus with grading type, due weeks etc.', () => {
@@ -239,6 +245,7 @@ describe('<XBlockStatus /> for Instructor paced Subsection', () => {
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('renders XBlockStatus with release status, grading type, due date etc.', () => {
@@ -368,6 +375,7 @@ describe('<XBlockStatus /> for self paced Subsection', () => {
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('renders XBlockStatus with grading type, due weeks etc.', () => {
@@ -448,6 +456,7 @@ describe('<XBlockStatus /> for unit', () => {
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('renders XBlockStatus with status messages', () => {

View File

@@ -1,11 +1,13 @@
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useNavigate } from 'react-router-dom';
import { RequestStatus } from '../data/constants';
import { updateSavingStatus } from '../generic/data/slice';
import {
getSavingStatus,
getRedirectUrlObj,
getCourseRerunData,
getCourseData,
} from '../generic/data/selectors';
@@ -15,9 +17,11 @@ import { fetchStudioHomeData } from '../studio-home/data/thunks';
const useCourseRerun = (courseId) => {
const intl = useIntl();
const dispatch = useDispatch();
const navigate = useNavigate();
const savingStatus = useSelector(getSavingStatus);
const courseData = useSelector(getCourseData);
const courseRerunData = useSelector(getCourseRerunData);
const redirectUrlObj = useSelector(getRedirectUrlObj);
const {
displayName = '',
@@ -42,6 +46,10 @@ const useCourseRerun = (courseId) => {
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
dispatch(updateSavingStatus({ status: '' }));
const { url } = redirectUrlObj;
if (url) {
navigate('/home');
}
}
}, [savingStatus]);

View File

@@ -22,7 +22,7 @@ const messages = defineMessages({
defaultMessage: 'Add admin access',
},
removeButton: {
id: 'course-authoring.course-team.member.button.remove-admin-access',
id: 'course-authoring.course-team.member.button.remove',
defaultMessage: 'Remove admin access',
},
deleteUserButton: {

View File

@@ -19,33 +19,33 @@ import messages from './info-modal/messages';
const getInfoModalSettings = (modalType, currentEmail, errorMessage, courseName, intl) => {
switch (modalType) {
case MODAL_TYPES.delete:
return {
title: intl.formatMessage(messages.deleteModalTitle),
message: intl.formatMessage(messages.deleteModalMessage, { email: currentEmail, courseName }),
variant: '',
closeButtonText: intl.formatMessage(messages.deleteModalCancelButton),
submitButtonText: intl.formatMessage(messages.deleteModalDeleteButton),
closeButtonVariant: 'tertiary',
};
case MODAL_TYPES.error:
return {
title: intl.formatMessage(messages.errorModalTitle),
message: errorMessage,
variant: 'danger',
closeButtonText: intl.formatMessage(messages.errorModalOkButton),
closeButtonVariant: 'primary',
};
case MODAL_TYPES.warning:
return {
title: intl.formatMessage(messages.warningModalTitle),
message: intl.formatMessage(messages.warningModalMessage, { email: currentEmail, courseName }),
variant: 'warning',
closeButtonText: intl.formatMessage(messages.warningModalReturnButton),
mainButtonVariant: 'primary',
};
default:
return '';
case MODAL_TYPES.delete:
return {
title: intl.formatMessage(messages.deleteModalTitle),
message: intl.formatMessage(messages.deleteModalMessage, { email: currentEmail, courseName }),
variant: '',
closeButtonText: intl.formatMessage(messages.deleteModalCancelButton),
submitButtonText: intl.formatMessage(messages.deleteModalDeleteButton),
closeButtonVariant: 'tertiary',
};
case MODAL_TYPES.error:
return {
title: intl.formatMessage(messages.errorModalTitle),
message: errorMessage,
variant: 'danger',
closeButtonText: intl.formatMessage(messages.errorModalOkButton),
closeButtonVariant: 'primary',
};
case MODAL_TYPES.warning:
return {
title: intl.formatMessage(messages.warningModalTitle),
message: intl.formatMessage(messages.warningModalMessage, { email: currentEmail, courseName }),
variant: 'warning',
closeButtonText: intl.formatMessage(messages.warningModalReturnButton),
mainButtonVariant: 'primary',
};
default:
return '';
}
};

View File

@@ -7,8 +7,8 @@ import { getConfig } from '@edx/frontend-platform';
import { useIntl, injectIntl } from '@edx/frontend-platform/i18n';
import { Warning as WarningIcon } from '@openedx/paragon/icons';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { DraggableList } from '@edx/frontend-lib-content-components';
import DraggableList from '../editors/sharedComponents/DraggableList';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
import SubHeader from '../generic/sub-header/SubHeader';
import { RequestStatus } from '../data/constants';

View File

@@ -1,6 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import {
act, render, waitFor, fireEvent, within, screen,
act, render, waitFor, fireEvent, within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -525,19 +525,17 @@ describe('<CourseUnit />', () => {
});
it('should display a warning alert for unpublished course unit version', async () => {
render(<RootWrapper />);
const { getByRole } = render(<RootWrapper />);
await waitFor(() => {
const unpublishedAlert = screen.getAllByRole('alert').find(
(el) => el.classList.contains('alert-content'),
);
const unpublishedAlert = getByRole('alert', { class: 'course-unit-unpublished-alert' });
expect(unpublishedAlert).toHaveTextContent(messages.alertUnpublishedVersion.defaultMessage);
expect(unpublishedAlert).toHaveClass('alert-warning');
});
});
it('should not display an unpublished alert for a course unit with explicit staff lock and unpublished status', async () => {
render(<RootWrapper />);
const { queryByRole } = render(<RootWrapper />);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
@@ -549,10 +547,8 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await waitFor(() => {
const alert = screen.queryAllByRole('alert').find(
(el) => el.classList.contains('alert-content'),
);
expect(alert).toBeUndefined();
const unpublishedAlert = queryByRole('alert', { class: 'course-unit-unpublished-alert' });
expect(unpublishedAlert).toBeNull();
});
});

View File

@@ -5,7 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { useToggle } from '@openedx/paragon';
import { getCourseSectionVertical } from '../data/selectors';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import { COMPONENT_TYPES } from '../constants';
import ComponentModalView from './add-component-modals/ComponentModalView';
import AddComponentButton from './add-component-btn';
import messages from './messages';
@@ -20,41 +20,41 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
const handleCreateNewXBlock = (type, moduleName) => {
switch (type) {
case COMPONENT_TYPES.discussion:
case COMPONENT_TYPES.dragAndDrop:
handleCreateNewCourseXBlock({ type, parentLocator: blockId });
break;
case COMPONENT_TYPES.problem:
case COMPONENT_TYPES.video:
handleCreateNewCourseXBlock({ type, parentLocator: blockId }, ({ courseKey, locator }) => {
navigate(`/course/${courseKey}/editor/${type}/${locator}`);
});
break;
// TODO: The library functional will be a bit different of current legacy (CMS)
// behaviour and this ticket is on hold (blocked by other development team).
case COMPONENT_TYPES.library:
handleCreateNewCourseXBlock({ type, category: 'library_content', parentLocator: blockId });
break;
case COMPONENT_TYPES.advanced:
handleCreateNewCourseXBlock({
type: moduleName, category: moduleName, parentLocator: blockId,
});
break;
case COMPONENT_TYPES.openassessment:
handleCreateNewCourseXBlock({
boilerplate: moduleName, category: type, parentLocator: blockId,
});
break;
case COMPONENT_TYPES.html:
handleCreateNewCourseXBlock({
type,
boilerplate: moduleName,
parentLocator: blockId,
}, ({ courseKey, locator }) => {
navigate(`/course/${courseKey}/editor/html/${locator}`);
});
break;
default:
case COMPONENT_TYPES.discussion:
case COMPONENT_TYPES.dragAndDrop:
handleCreateNewCourseXBlock({ type, parentLocator: blockId });
break;
case COMPONENT_TYPES.problem:
case COMPONENT_TYPES.video:
handleCreateNewCourseXBlock({ type, parentLocator: blockId }, ({ courseKey, locator }) => {
navigate(`/course/${courseKey}/editor/${type}/${locator}`);
});
break;
// TODO: The library functional will be a bit different of current legacy (CMS)
// behaviour and this ticket is on hold (blocked by other development team).
case COMPONENT_TYPES.library:
handleCreateNewCourseXBlock({ type, category: 'library_content', parentLocator: blockId });
break;
case COMPONENT_TYPES.advanced:
handleCreateNewCourseXBlock({
type: moduleName, category: moduleName, parentLocator: blockId,
});
break;
case COMPONENT_TYPES.openassessment:
handleCreateNewCourseXBlock({
boilerplate: moduleName, category: type, parentLocator: blockId,
});
break;
case COMPONENT_TYPES.html:
handleCreateNewCourseXBlock({
type,
boilerplate: moduleName,
parentLocator: blockId,
}, ({ courseKey, locator }) => {
navigate(`/course/${courseKey}/editor/html/${locator}`);
});
break;
default:
}
};
@@ -75,37 +75,37 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
}
switch (type) {
case COMPONENT_TYPES.advanced:
modalParams = {
open: openAdvanced,
close: closeAdvanced,
isOpen: isOpenAdvanced,
};
break;
case COMPONENT_TYPES.html:
modalParams = {
open: openHtml,
close: closeHtml,
isOpen: isOpenHtml,
};
break;
case COMPONENT_TYPES.openassessment:
modalParams = {
open: openOpenAssessment,
close: closeOpenAssessment,
isOpen: isOpenOpenAssessment,
};
break;
default:
return (
<li key={type}>
<AddComponentButton
onClick={() => handleCreateNewXBlock(type)}
displayName={displayName}
type={type}
/>
</li>
);
case COMPONENT_TYPES.advanced:
modalParams = {
open: openAdvanced,
close: closeAdvanced,
isOpen: isOpenAdvanced,
};
break;
case COMPONENT_TYPES.html:
modalParams = {
open: openHtml,
close: closeHtml,
isOpen: isOpenHtml,
};
break;
case COMPONENT_TYPES.openassessment:
modalParams = {
open: openOpenAssessment,
close: closeOpenAssessment,
isOpen: isOpenOpenAssessment,
};
break;
default:
return (
<li key={type}>
<AddComponentButton
onClick={() => handleCreateNewXBlock(type)}
displayName={displayName}
type={type}
/>
</li>
);
}
return (

View File

@@ -14,7 +14,7 @@ import { executeThunk } from '../../utils';
import { fetchCourseSectionVerticalData } from '../data/thunk';
import { getCourseSectionVerticalApiUrl } from '../data/api';
import { courseSectionVerticalMock } from '../__mocks__';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import { COMPONENT_TYPES } from '../constants';
import AddComponent from './AddComponent';
import messages from './messages';

View File

@@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import { Icon } from '@openedx/paragon';
import { EditNote as EditNoteIcon } from '@openedx/paragon/icons';
import { COMPONENT_TYPES, COMPONENT_TYPE_ICON_MAP } from '../../../generic/block-type-utils/constants';
import { COMPONENT_TYPES, COMPONENT_TYPE_ICON_MAP } from '../../constants';
const AddComponentIcon = ({ type }) => {
const icon = COMPONENT_TYPE_ICON_MAP[type] || EditNoteIcon;

View File

@@ -42,7 +42,7 @@ const ComponentModalView = ({
<ModalContainer
isOpen={isOpen}
close={close}
title={intl.formatMessage(messages.modalContainerTitle, { componentTitle: (displayName ?? '').toLowerCase() })}
title={intl.formatMessage(messages.modalContainerTitle, { componentTitle: displayName.toLowerCase() })}
btnText={intl.formatMessage(messages.modalBtnText)}
onSubmit={handleSubmit}
resetDisabled={() => setModuleTitle('')}

View File

@@ -1,6 +1,53 @@
import {
BackHand as BackHandIcon,
BookOpen as BookOpenIcon,
Edit as EditIcon,
EditNote as EditNoteIcon,
FormatListBulleted as FormatListBulletedIcon,
HelpOutline as HelpOutlineIcon,
LibraryAdd as LibraryIcon,
Lock as LockIcon,
QuestionAnswerOutline as QuestionAnswerOutlineIcon,
Science as ScienceIcon,
TextFields as TextFieldsIcon,
VideoCamera as VideoCameraIcon,
} from '@openedx/paragon/icons';
import messages from './sidebar/messages';
import addComponentMessages from './add-component/messages';
export const UNIT_ICON_TYPES = ['video', 'other', 'vertical', 'problem', 'lock'];
export const COMPONENT_TYPES = {
advanced: 'advanced',
discussion: 'discussion',
library: 'library',
html: 'html',
openassessment: 'openassessment',
problem: 'problem',
video: 'video',
dragAndDrop: 'drag-and-drop-v2',
};
export const TYPE_ICONS_MAP = {
video: VideoCameraIcon,
other: BookOpenIcon,
vertical: FormatListBulletedIcon,
problem: EditIcon,
lock: LockIcon,
};
export const COMPONENT_TYPE_ICON_MAP = {
[COMPONENT_TYPES.advanced]: ScienceIcon,
[COMPONENT_TYPES.discussion]: QuestionAnswerOutlineIcon,
[COMPONENT_TYPES.library]: LibraryIcon,
[COMPONENT_TYPES.html]: TextFieldsIcon,
[COMPONENT_TYPES.openassessment]: EditNoteIcon,
[COMPONENT_TYPES.problem]: HelpOutlineIcon,
[COMPONENT_TYPES.video]: VideoCameraIcon,
[COMPONENT_TYPES.dragAndDrop]: BackHandIcon,
};
export const getUnitReleaseStatus = (intl) => ({
release: intl.formatMessage(messages.releaseStatusTitle),
released: intl.formatMessage(messages.releasedStatusTitle),

View File

@@ -2,10 +2,10 @@ import PropTypes from 'prop-types';
import { Icon } from '@openedx/paragon';
import { BookOpen as BookOpenIcon } from '@openedx/paragon/icons';
import { UNIT_TYPE_ICONS_MAP, UNIT_ICON_TYPES } from '../../../generic/block-type-utils/constants';
import { TYPE_ICONS_MAP, UNIT_ICON_TYPES } from '../../constants';
const UnitIcon = ({ type }) => {
const icon = UNIT_TYPE_ICONS_MAP[type] || BookOpenIcon;
const icon = TYPE_ICONS_MAP[type] || BookOpenIcon;
return <Icon src={icon} screenReaderText={type} />;
};

View File

@@ -7,7 +7,7 @@ import {
} from '@openedx/paragon';
import { EditOutline as EditIcon, MoreVert as MoveVertIcon } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSearchParams } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { getCanEdit, getCourseId } from 'CourseAuthoring/course-unit/data/selectors';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
@@ -16,10 +16,9 @@ import SortableItem from '../../generic/drag-helper/SortableItem';
import { scrollToElement } from '../../course-outline/utils';
import { COURSE_BLOCK_NAMES } from '../../constants';
import { copyToClipboard } from '../../generic/data/thunks';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import { COMPONENT_TYPES } from '../constants';
import XBlockMessages from './xblock-messages/XBlockMessages';
import messages from './messages';
import { createCorrectInternalRoute } from '../../utils';
const CourseXBlock = ({
id, title, type, unitXBlockActions, shouldScroll, userPartitionInfo,
@@ -29,6 +28,7 @@ const CourseXBlock = ({
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const dispatch = useDispatch();
const navigate = useNavigate();
const canEdit = useSelector(getCanEdit);
const courseId = useSelector(getCourseId);
const intl = useIntl();
@@ -55,16 +55,12 @@ const CourseXBlock = ({
const handleEdit = () => {
switch (type) {
case COMPONENT_TYPES.html:
case COMPONENT_TYPES.problem:
case COMPONENT_TYPES.video:
// Not using useNavigate from react router to use browser navigation
// which allows us to block back button if unsaved changes in editor are present.
window.location.assign(
createCorrectInternalRoute(`/course/${courseId}/editor/${type}/${id}`),
);
break;
default:
case COMPONENT_TYPES.html:
case COMPONENT_TYPES.problem:
case COMPONENT_TYPES.video:
navigate(`/course/${courseId}/editor/${type}/${id}`);
break;
default:
}
};

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