Compare commits

..

21 Commits

Author SHA1 Message Date
Max Sokolski
d43579321b chore(deps): update dependency @edx/frontend-lib-content-components to v2.5.3 (#1410) 2024-10-21 15:31:14 +03:00
Stanislav
4aed4dee15 fix: Calendar icon over datepicker modal (#1366) 2024-10-16 10:21:22 -04:00
Raymond Zhou
0189e919a6 Revert "fix: layout responsive for edit page (#1058)" (#1325)
This reverts commit 65e431cebe.
2024-09-25 07:51:04 -04:00
Ihor Romaniuk
65e431cebe fix: layout responsive for edit page (#1058) 2024-09-25 07:49:31 -04:00
Stanislav
af4e25b39f fix: Fix content overflow in the Pages & Resources modal windows (#1302) 2024-09-19 15:16:48 -04:00
Kaustav Banerjee
0589912714 feat: backport: remove new library button if user does not have create access for v1 libraries (#1282) 2024-09-19 09:23:29 -07:00
Stanislav
4ff3684772 fix: Add missed translation for Lock File tooltip (#1297) 2024-09-18 10:18:19 -04:00
Stanislav
0ae7aa0265 fix: Fix content overflow in the Overwrite Files modal window (#1292) 2024-09-17 10:28:33 -04:00
Dmytro
dc7bb9fe04 fix: create course button inactive after using org drop-down (#1277)
Co-authored-by: Dima Alipov <dimaalipov@192.168.1.101>
2024-09-12 12:46:00 -04:00
Dmytro
8abcbe0385 fix: hide due dates config and add discussion enable setting (#1267)
Hide release and due dates config in self paced courses and
discussion enable setting for unit in outline.

Co-authored-by: Dima Alipov <dimaalipov@192.168.1.101>
2024-09-10 11:32:18 -04:00
Dmytro
455656265e fix: no validation for combined length of org, number, run (#1261)
Co-authored-by: Dima Alipov <dimaalipov@192.168.1.101>
2024-09-10 10:24:39 -04:00
Muhammad Anas
8518933970 feat: customize the certificate link in header (#1223) (#1225)
* feat: customize the certificate link in header

* fix: lint issues

* fix: tests
2024-08-21 13:38:38 -04:00
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
1358 changed files with 22103 additions and 116152 deletions

7
.env
View File

@@ -1,4 +1,3 @@
APP_ID='authoring'
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=''
BASE_URL=''
@@ -41,9 +40,7 @@ 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
# "Multi-level" blocks are unsupported in libraries
# TODO: Missing support for ORA2
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"

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'
@@ -44,8 +43,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
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"

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'
@@ -39,6 +38,3 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
# TODO: Missing support for ORA2
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"

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,27 +9,14 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
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-file: '.nvmrc'
node-version: ${{ env.NODE_VER }}
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v4
with:
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
- name: Upload coverage
uses: codecov/codecov-action@v4
with:

1
.gitignore vendored
View File

@@ -1,7 +1,6 @@
.DS_Store
.eslintcache
.idea
.run
node_modules
npm-debug.log
coverage

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 the version of Node specified in the ``.nvmrc`` file.
``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,26 +264,10 @@ 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
**********
`Tutor <https://docs.tutor.edly.io/>`_ is the community-supported Open edX development environment. See the `tutor-mfe plugin README <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`_ for more information.
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
If your devstack includes the default Demo course, you can visit the following URLs to see content:
@@ -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,15 +4,14 @@
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: ""
openedx.org/release: "master"
spec:
owner: group:2u-tnl
type: 'website'

11
openedx.yaml Normal file
View File

@@ -0,0 +1,11 @@
# This file describes this Open edX repo, as described in OEP-2:
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
nick: cath
oeps: {}
owner: edx/platform-core-tnl
openedx-release:
# The openedx-release key is described in OEP-10:
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
ref: master

26380
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,42 +13,47 @@
"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": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"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.3.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.3.1",
"@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-lib-content-components": "2.5.3",
"@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",
@@ -59,66 +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.3.3",
"@openedx/frontend-plugin-framework": "^1.6.0",
"@openedx/frontend-slot-footer": "^1.2.0",
"@openedx/paragon": "^22.16.0",
"@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": "^18.3.1",
"react": "17.0.2",
"react-datepicker": "^4.13.0",
"react-dom": "^18.3.1",
"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": "^4.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",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@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": "^18.3.1",
"redux-mock-store": "^1.5.4"
"react-test-renderer": "17.0.2",
"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

@@ -1,3 +1,4 @@
/* eslint-disable import/prefer-default-export */
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { bbbPlanTypes } from '../constants';

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 = await screen.findByText(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);
@@ -544,9 +569,12 @@ describe('ProctoredExamSettings', () => {
describe('Connection states', () => {
it('Shows the spinner before the connection is complete', async () => {
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
await act(async () => {
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
// This expectation is _inside_ the `act` intentionally, so that it executes immediately.
const spinner = screen.getByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
});
it('Show connection error message when we suffer studio server side error', async () => {
@@ -600,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');
@@ -610,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: {
@@ -628,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 () => {
@@ -644,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: {
@@ -655,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);
@@ -696,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({
@@ -726,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 () => {
@@ -752,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
@@ -766,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 () => {
@@ -782,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 () => {
@@ -800,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 () => {
@@ -837,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 () => {
@@ -871,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

@@ -26,7 +26,6 @@ const TeamSettings = ({
description: '',
type: GroupTypes.OPEN,
maxTeamSize: null,
userPartitionId: null,
id: null,
key: uuid(),
};
@@ -39,7 +38,6 @@ const TeamSettings = ({
type: group.type,
description: group.description,
max_team_size: group.maxTeamSize,
user_partition_id: group.userPartitionId,
}));
return saveSettings({
team_sets: groups,

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

@@ -1,3 +1,4 @@
/* eslint-disable import/prefer-default-export */
import { getConfig } from '@edx/frontend-platform';
import { GroupTypes } from 'CourseAuthoring/data/constants';

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

@@ -7,7 +7,7 @@ import {
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import {
findByTestId, queryByTestId, render, waitFor, getByText, fireEvent,
queryByTestId, render, waitFor, getByText, fireEvent,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
@@ -106,9 +106,8 @@ describe('XpertUnitSummarySettings', () => {
});
test('Shows switch on if enabled from backend', async () => {
const enableBadge = await findByTestId(container, 'enable-badge');
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).toBeTruthy();
expect(enableBadge).toBeTruthy();
expect(queryByTestId(container, 'enable-badge')).toBeTruthy();
});
test('Shows switch on if disabled from backend', async () => {

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

@@ -5,29 +5,46 @@ import { useDispatch, useSelector } from 'react-redux';
import {
useLocation,
} from 'react-router-dom';
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
import { StudioFooter } from '@edx/frontend-component-footer';
import Header from './header';
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
import { fetchCourseDetail } from './data/thunks';
import { useModel } from './generic/model-store';
import NotFoundAlert from './generic/NotFoundAlert';
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
import { fetchOnlyStudioHomeData } 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();
useEffect(() => {
dispatch(fetchCourseDetail(courseId));
dispatch(fetchWaffleFlags(courseId));
}, [courseId]);
useEffect(() => {
dispatch(fetchOnlyStudioHomeData());
}, []);
const courseDetail = useModel('courseDetails', courseId);
const courseNumber = courseDetail ? courseDetail.number : null;
@@ -50,23 +67,23 @@ 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}
/>
)
)}
{children}
{!inProgress && !isEditor && <StudioFooterSlot />}
{!inProgress && !isEditor && <StudioFooter />}
</div>
);
};

View File

@@ -1,12 +1,18 @@
import { getConfig } from '@edx/frontend-platform';
import React from 'react';
import { render } from '@testing-library/react';
import { getConfig, 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 { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from './store';
import CourseAuthoringPage from './CourseAuthoringPage';
import PagesAndResources from './pages-and-resources/PagesAndResources';
import { executeThunk } from './utils';
import { fetchCourseApps } from './pages-and-resources/data/thunks';
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
import { getApiWaffleFlagsUrl } from './data/api';
import { initializeMocks, render } from './testUtils';
import { fetchCourseDetail } from './data/thunks';
const courseId = 'course-v1:edX+TestX+Test_Course';
let mockPathname = '/evilguy/';
@@ -19,14 +25,17 @@ jest.mock('react-router-dom', () => ({
let axiosMock;
let store;
beforeEach(async () => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {});
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
describe('Editor Pages Load no header', () => {
@@ -42,9 +51,13 @@ describe('Editor Pages Load no header', () => {
mockPathname = '/editor/';
await mockStoreSuccess();
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
</IntlProvider>
</AppProvider>
,
);
expect(wrapper.queryByRole('status')).not.toBeInTheDocument();
@@ -53,9 +66,13 @@ describe('Editor Pages Load no header', () => {
mockPathname = '/evilguy/';
await mockStoreSuccess();
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
</IntlProvider>
</AppProvider>
,
);
expect(wrapper.queryByRole('status')).toBeInTheDocument();
@@ -83,7 +100,14 @@ describe('Course authoring page', () => {
};
test('renders not found page on non-existent course key', async () => {
await mockStoreNotFound();
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
const wrapper = render(
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId} />
</IntlProvider>
</AppProvider>
,
);
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
});
test('does not render not found page on other kinds of error', async () => {
@@ -94,9 +118,13 @@ describe('Course authoring page', () => {
// found alert is not present.
const contentTestId = 'courseAuthoringPageContent';
const wrapper = render(
<CourseAuthoringPage courseId={courseId}>
<div data-testid={contentTestId} />
</CourseAuthoringPage>
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId}>
<div data-testid={contentTestId} />
</CourseAuthoringPage>
</IntlProvider>
</AppProvider>
,
);
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();

View File

@@ -20,13 +20,10 @@ import { CourseUpdates } from './course-updates';
import { CourseUnit } from './course-unit';
import { Certificates } from './certificates';
import CourseExportPage from './export-page/CourseExportPage';
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
import GroupConfigurations from './group-configurations';
import { CourseLibraries } from './course-libraries';
import { IframeProvider } from './generic/hooks/context/iFrameContext';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -58,10 +55,6 @@ const CourseAuthoringRoutes = () => {
path="course_info"
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
/>
<Route
path="libraries"
element={<PageWrap><CourseLibraries courseId={courseId} /></PageWrap>}
/>
<Route
path="assets"
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
@@ -86,7 +79,7 @@ const CourseAuthoringRoutes = () => {
<Route
key={path}
path={path}
element={<PageWrap><IframeProvider><CourseUnit courseId={courseId} /></IframeProvider></PageWrap>}
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
/>
))}
<Route
@@ -95,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"
@@ -125,10 +118,6 @@ const CourseAuthoringRoutes = () => {
path="export"
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="optimizer"
element={<PageWrap><CourseOptimizerPage courseId={courseId} /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}

View File

@@ -1,10 +1,10 @@
import React from 'react';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
import { executeThunk } from './utils';
import { getApiWaffleFlagsUrl } from './data/api';
import { fetchWaffleFlags } from './data/thunks';
import {
screen, initializeMocks, render, waitFor,
} from './testUtils';
import initializeStore from './store';
const courseId = 'course-v1:edX+TestX+Test_Course';
const pagesAndResourcesMockText = 'Pages And Resources';
@@ -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,
@@ -50,59 +49,68 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
});
describe('<CourseAuthoringRoutes>', () => {
beforeEach(async () => {
const { axiosMock, reduxStore } = initializeMocks();
store = reduxStore;
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {});
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('renders the PagesAndResources component when the pages and resources route is active', async () => {
fit('renders the PagesAndResources component when the pages and resources route is active', () => {
render(
<CourseAuthoringRoutes />,
{ routerProps: { initialEntries: ['/pages-and-resources'] } },
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/pages-and-resources']}>
<CourseAuthoringRoutes />
</MemoryRouter>
</AppProvider>,
);
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
await waitFor(() => {
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
});
it('renders the EditorContainer component when the course editor route is active', async () => {
it('renders the EditorContainer component when the course editor route is active', () => {
render(
<CourseAuthoringRoutes />,
{ routerProps: { initialEntries: ['/editor/video/block-id'] } },
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/editor/video/block-id']}>
<CourseAuthoringRoutes />
</MemoryRouter>
</AppProvider>,
);
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
await waitFor(() => {
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
learningContextId: courseId,
}),
);
});
});
it('renders the VideoSelectorContainer component when the course videos route is active', async () => {
it('renders the VideoSelectorContainer component when the course videos route is active', () => {
render(
<CourseAuthoringRoutes />,
{ routerProps: { initialEntries: ['/editor/course-videos/block-id'] } },
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={['/editor/course-videos/block-id']}>
<CourseAuthoringRoutes />
</MemoryRouter>
</AppProvider>,
);
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
await waitFor(() => {
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
});
});

View File

@@ -1,16 +0,0 @@
export default {
content: {
id: 67,
userId: 3,
created: '2024-01-16T13:09:11.540615Z',
purpose: 'clipboard',
status: 'ready',
blockType: 'sequential',
blockTypeDisplay: 'Subsection',
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
displayName: 'Sequences',
},
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
sourceContextTitle: 'Demonstration Course',
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
};

View File

@@ -1,3 +1,2 @@
export { default as clipboardUnit } from './clipboardUnit';
export { default as clipboardSubsection } from './clipboardSubsection';
export { default as clipboardXBlock } from './clipboardXBlock';

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { Container } from '@openedx/paragon';
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
import { StudioFooter } from '@edx/frontend-component-footer';
import Header from '../header';
import messages from './messages';
@@ -29,7 +29,7 @@ const AccessibilityPage = ({
<AccessibilityBody {...{ email, communityAccessibilityLink }} />
<AccessibilityForm accessibilityEmail={email} />
</Container>
<StudioFooterSlot />
<StudioFooter />
</>
);
};

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

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

View File

@@ -1,3 +1,4 @@
/* eslint-disable import/prefer-default-export */
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { convertObjectToSnakeCase } from '../../utils';

View File

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

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

@@ -1,5 +1,6 @@
import { v4 as uuid } from 'uuid';
// eslint-disable-next-line import/prefer-default-export
export const defaultCertificate = {
courseTitle: '',
signatories: [{

View File

@@ -1,3 +1,4 @@
/* eslint-disable import/prefer-default-export */
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

View File

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

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line import/prefer-default-export
export const getSidebarData = ({ messages, intl }) => [
{
title: intl.formatMessage(messages.workingWithCertificatesTitle),

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

@@ -1,5 +1,6 @@
import { convertObjectToSnakeCase } from '../utils';
// eslint-disable-next-line import/prefer-default-export
export const prepareCertificatePayload = (data) => convertObjectToSnakeCase(({
...data,
courseTitle: data.courseTitle,

View File

@@ -27,8 +27,6 @@ export const NOTIFICATION_MESSAGES = {
copying: 'Copying',
pasting: 'Pasting',
discardChanges: 'Discarding changes',
moving: 'Moving',
undoMoving: 'Undo moving',
publishing: 'Publishing',
hidingFromStudents: 'Hiding from students',
makingVisibleToStudents: 'Making visible to students',
@@ -58,8 +56,6 @@ export const COURSE_BLOCK_NAMES = ({
chapter: { id: 'chapter', name: 'Section' },
sequential: { id: 'sequential', name: 'Subsection' },
vertical: { id: 'vertical', name: 'Unit' },
libraryContent: { id: 'library_content', name: 'Library content' },
splitTest: { id: 'split_test', name: 'Split Test' },
component: { id: 'component', name: 'Component' },
});
@@ -73,36 +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*$/,
};
/**
* Feature policy for iframe, allowing access to certain courseware-related media.
*
* We must use the wildcard (*) origin for each feature, as courseware content
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
* block that iframes external course content.
* This policy was selected in conference with the edX Security Working Group.
* Changes to it should be vetted by them (security@edx.org).
*/
export const IFRAME_FEATURE_POLICY = (
'microphone *; camera *; midi *; geolocation *; encrypted-media *; clipboard-write *'
);
export const iframeStateKeys = {
iframeHeight: 'iframeHeight',
hasLoaded: 'hasLoaded',
showError: 'showError',
windowTopOffset: 'windowTopOffset',
};
export const iframeMessageTypes = {
modal: 'plugin.modal',
resize: 'plugin.resize',
videoFullScreen: 'plugin.videoFullScreen',
xblockEvent: 'xblock-event',
};

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';
@@ -25,9 +24,9 @@ import TagsTree from './TagsTree';
import { ContentTagsDrawerContext } from './common/context';
/** @typedef {import("./ContentTagsCollapsible").TaxonomySelectProps} TaxonomySelectProps */
/** @typedef {import("../taxonomy/data/types.js").TaxonomyData} TaxonomyData */
/** @typedef {import("./data/types.js").Tag} ContentTagData */
/** @typedef {import("./data/types.js").StagedTagData} StagedTagData */
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
/** @typedef {import("./data/types.mjs").StagedTagData} StagedTagData */
/**
* Custom Menu component for our Select box
@@ -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);
@@ -699,7 +675,7 @@ describe('<ContentTagsCollapsible />', () => {
const xButtonAppliedTag = within(appliedTag).getByRole('button', {
name: /delete/i,
});
await userEvent.click(xButtonAppliedTag);
xButtonAppliedTag.click();
// Check that the applied tag has been removed
expect(appliedTag).not.toBeInTheDocument();

View File

@@ -6,11 +6,11 @@ import { cloneDeep } from 'lodash';
import { useContentTaxonomyTagsUpdater } from './data/apiHooks';
import { ContentTagsDrawerContext } from './common/context';
/** @typedef {import("../taxonomy/data/types.js").TaxonomyData} TaxonomyData */
/** @typedef {import("./data/types.js").Tag} ContentTagData */
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
/** @typedef {import("./ContentTagsCollapsible").TagTreeEntry} TagTreeEntry */
/** @typedef {import("./data/types.js").StagedTagData} StagedTagData */
/** @typedef {import("./data/types.js").UpdateTagsData} UpdateTagsData */
/** @typedef {import("./data/types.mjs").StagedTagData} StagedTagData */
/** @typedef {import("./data/types.mjs").UpdateTagsData} UpdateTagsData */
/**
* Util function that sorts the keys of a tree in alphabetical order.
@@ -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,111 +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 () => {
renderDrawer(stagedTagsId);
const spinner = (await screen.findAllByRole('status'))[0];
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
await act(async () => {
const { getAllByRole } = render(<RootWrapper />);
const spinner = getAllByRole('status')[0];
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
});
});
it('shows spinner before the taxonomy tags query is complete', async () => {
renderDrawer(stagedTagsId);
const spinner = (await screen.findAllByRole('status'))[1];
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
await act(async () => {
const { getAllByRole } = render(<RootWrapper />);
const spinner = getAllByRole('status')[1];
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
});
});
it('shows the content display name after the query is complete 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 () => {
const { container } = renderDrawer(largeTagsId);
await screen.findByText('Taxonomy 1');
await screen.findByText('Taxonomy 2');
const tagCountBadges = container.getElementsByClassName('taxonomy-tags-count-chip');
expect(tagCountBadges[0].textContent).toBe('3');
expect(tagCountBadges[1].textContent).toBe('2');
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, 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('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();
@@ -120,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,
@@ -161,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,
@@ -210,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
@@ -275,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');
@@ -286,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
@@ -301,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');
@@ -316,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
@@ -333,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');
@@ -352,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
@@ -367,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
@@ -383,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
@@ -394,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,
});
@@ -410,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
@@ -425,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
@@ -450,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
@@ -461,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,
});
@@ -475,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
@@ -492,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,
@@ -506,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',
@@ -518,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',
@@ -530,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');
@@ -550,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');
@@ -569,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',
});
@@ -580,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',
});
@@ -592,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);
@@ -612,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
@@ -623,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);
@@ -648,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,
@@ -663,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
@@ -687,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();
@@ -704,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
@@ -730,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,398 +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 { useCreateContentTagsDrawerContext } 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.
* - 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 = useCreateContentTagsDrawerContext(contentId, !readOnly, variant === 'drawer');
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

@@ -8,23 +8,45 @@ import { extractOrgFromContentId, languageExportId } from './utils';
import messages from './messages';
import { ContentTagsDrawerSheetContext } from './common/context';
/** @typedef {import("./data/types.js").Tag} ContentTagData */
/** @typedef {import("./data/types.js").StagedTagData} StagedTagData */
/** @typedef {import("./data/types.js").TagsInTaxonomy} TagsInTaxonomy */
/** @typedef {import("./common/context").ContentTagsDrawerContextData} ContentTagsDrawerContextData */
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
/** @typedef {import("./data/types.mjs").StagedTagData} StagedTagData */
/** @typedef {import("./data/types.mjs").TagsInTaxonomy} TagsInTaxonomy */
/**
* Helper hook for *creating* a `ContentTagsDrawerContext`.
* Handles the context and all the underlying logic for the ContentTagsDrawer component.
*
* To *use* the context, just use `useContext(ContentTagsDrawerContext)`
* Handles the context and all the underlying logic for the ContentTagsDrawer component
* @param {string} contentId
* @param {boolean} canTagObject
* @param {boolean} fetchMetadata=false If true, fetches metadata for the contentId. This is used on `edx-platform`
* and the Course/Unit Outline to show the content name as the drawer title.
* @returns {ContentTagsDrawerContextData}
* @returns {{
* stagedContentTags: Record<number, StagedTagData[]>,
* addStagedContentTag: (taxonomyId: number, addedTag: StagedTagData) => void,
* removeStagedContentTag: (taxonomyId: number, tagValue: string) => void,
* removeGlobalStagedContentTag: (taxonomyId: number, tagValue: string) => void,
* addRemovedContentTag: (taxonomyId: number, tagValue: string) => void,
* deleteRemovedContentTag: (taxonomyId: number, tagValue: string) => void,
* setStagedTags: (taxonomyId: number, tagsList: StagedTagData[]) => void,
* globalStagedContentTags: Record<number, StagedTagData[]>,
* globalStagedRemovedContentTags: Record<number, string>,
* setGlobalStagedContentTags: Function,
* commitGlobalStagedTags: () => void,
* commitGlobalStagedTagsStatus: string,
* isContentDataLoaded: boolean,
* isContentTaxonomyTagsLoaded: boolean,
* isTaxonomyListLoaded: boolean,
* contentName: string,
* tagsByTaxonomy: TagsInTaxonomy[],
* isEditMode: boolean,
* toEditMode: () => void,
* toReadMode: () => void,
* collapsibleStates: Record<number, boolean>,
* openCollapsible: (taxonomyId: number) => void,
* closeCollapsible: (taxonomyId: number) => void,
* toastMessage: string | undefined,
* showToastAfterSave: () => void,
* closeToast: () => void,
* setCollapsibleToInitalState: () => void,
* otherTaxonomies: TagsInTaxonomy[],
* }}
*/
export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetchMetadata = false) => {
const useContentTagsDrawerContext = (contentId) => {
const intl = useIntl();
const org = extractOrgFromContentId(contentId);
@@ -36,9 +58,9 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
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[] */ ([]));
@@ -50,15 +72,15 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
const updateTags = useContentTaxonomyTagsUpdater(contentId);
// Fetch from database
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId, fetchMetadata);
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId);
const {
data: contentTaxonomyTagsData,
isSuccess: isContentTaxonomyTagsLoaded,
} = 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,
@@ -93,7 +115,6 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
// Initialize list of content tags in taxonomies to populate
const taxonomiesList = taxonomyListData.results.map((taxonomy) => ({
...taxonomy,
canTagObject: taxonomy.canTagObject && canTagObject,
contentTags: /** @type {ContentTagData[]} */([]),
}));
@@ -128,13 +149,13 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
);
return {
fetchedTaxonomies: sortTaxonomies(filteredTaxonomies),
fetchedOtherTaxonomies: otherTaxonomiesList,
fechedTaxonomies: sortTaxonomies(filteredTaxonomies),
fechedOtherTaxonomies: otherTaxonomiesList,
};
}
return {
fetchedTaxonomies: [],
fetchedOtherTaxonomies: [],
fechedTaxonomies: [],
fechedOtherTaxonomies: [],
};
}, [taxonomyListData, contentTaxonomyTagsData]);
@@ -209,28 +230,28 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
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(() => {
@@ -310,7 +331,7 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
const closeToast = React.useCallback(() => setToastMessage(undefined), [setToastMessage]);
let contentName = '';
if (isContentDataLoaded && contentData) {
if (isContentDataLoaded) {
if ('displayName' in contentData) {
contentName = contentData.displayName;
} else {
@@ -318,14 +339,14 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
}
}
// 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 }
), {});
@@ -334,10 +355,10 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
// 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],
];
}
@@ -356,8 +377,8 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
});
// 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));
@@ -387,8 +408,8 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
}
}
}, [
fetchedTaxonomies,
fetchedOtherTaxonomies,
fechedTaxonomies,
fechedOtherTaxonomies,
globalStagedContentTags,
globalStagedRemovedContentTags,
]);
@@ -442,3 +463,5 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
otherTaxonomies,
};
};
export default useContentTagsDrawerContext;

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

@@ -1,6 +1,7 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import {
act,
render,
waitFor,
fireEvent,
@@ -73,9 +74,11 @@ describe('<ContentTagsDropDownSelector />', () => {
}
it('should render taxonomy tags drop down selector loading with spinner', async () => {
const { getByRole } = await getComponent();
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading tags'); // Uses <Spinner />
await act(async () => {
const { getByRole } = await getComponent();
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading tags'); // Uses <Spinner />
});
});
it('should render taxonomy tags drop down selector with no sub tags', async () => {
@@ -96,11 +99,13 @@ describe('<ContentTagsDropDownSelector />', () => {
},
});
const { container, getByText } = await getComponent();
await act(async () => {
const { container, getByText } = await getComponent();
await waitFor(() => {
expect(getByText('Tag 1')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0);
await waitFor(() => {
expect(getByText('Tag 1')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0);
});
});
});
@@ -122,11 +127,13 @@ describe('<ContentTagsDropDownSelector />', () => {
},
});
const { container, getByText } = await getComponent();
await act(async () => {
const { container, getByText } = await getComponent();
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
});
});
});
@@ -148,45 +155,47 @@ describe('<ContentTagsDropDownSelector />', () => {
},
});
const dataWithTagsTree = {
...data,
tagsTree: {
'Tag 3': {
explicit: false,
children: {},
await act(async () => {
const dataWithTagsTree = {
...data,
tagsTree: {
'Tag 3': {
explicit: false,
children: {},
},
},
},
};
const { container, getByText } = await getComponent(dataWithTagsTree);
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
});
};
const { container, getByText } = await getComponent(dataWithTagsTree);
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
});
// Mock useTaxonomyTagsData again since it gets called in the recursive call
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 3',
externalId: null,
childCount: 0,
depth: 1,
parentValue: 'Tag 2',
id: 12346,
subTagsUrl: null,
}],
},
});
// Mock useTaxonomyTagsData again since it gets called in the recursive call
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
data: [{
value: 'Tag 3',
externalId: null,
childCount: 0,
depth: 1,
parentValue: 'Tag 2',
id: 12346,
subTagsUrl: null,
}],
},
});
// Expand the dropdown to see the subtags selectors
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
fireEvent.click(expandToggle);
// Expand the dropdown to see the subtags selectors
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
fireEvent.click(expandToggle);
await waitFor(() => {
expect(getByText('Tag 3')).toBeInTheDocument();
await waitFor(() => {
expect(getByText('Tag 3')).toBeInTheDocument();
});
});
});
@@ -210,46 +219,48 @@ describe('<ContentTagsDropDownSelector />', () => {
});
const initalSearchTerm = 'test 1';
const { rerender } = await getComponent({ ...data, searchTerm: initalSearchTerm });
await act(async () => {
const { rerender } = await getComponent({ ...data, searchTerm: initalSearchTerm });
await waitFor(() => {
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, initalSearchTerm);
});
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, initalSearchTerm);
});
const updatedSearchTerm = 'test 2';
rerender(<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={updatedSearchTerm}
appliedContentTagsTree={{}}
stagedContentTagsTree={{}}
/>);
const updatedSearchTerm = 'test 2';
rerender(<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={updatedSearchTerm}
appliedContentTagsTree={{}}
stagedContentTagsTree={{}}
/>);
await waitFor(() => {
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, updatedSearchTerm);
});
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, updatedSearchTerm);
});
// Clean search term
const cleanSearchTerm = '';
rerender(<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={cleanSearchTerm}
appliedContentTagsTree={{}}
stagedContentTagsTree={{}}
/>);
// Clean search term
const cleanSearchTerm = '';
rerender(<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={cleanSearchTerm}
appliedContentTagsTree={{}}
stagedContentTagsTree={{}}
/>);
await waitFor(() => {
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, cleanSearchTerm);
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, cleanSearchTerm);
});
});
});
it('should render "noTag" message if search doesnt return taxonomies', async () => {
useTaxonomyTagsData.mockReturnValue({
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
@@ -260,18 +271,20 @@ describe('<ContentTagsDropDownSelector />', () => {
});
const searchTerm = 'uncommon search term';
const { getByText } = await getComponent({ ...data, searchTerm });
await act(async () => {
const { getByText } = await getComponent({ ...data, searchTerm });
await waitFor(() => {
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, searchTerm);
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
});
const message = `No tags found with the search term "${searchTerm}"`;
expect(getByText(message)).toBeInTheDocument();
});
const message = `No tags found with the search term "${searchTerm}"`;
expect(getByText(message)).toBeInTheDocument();
});
it('should render "noTagsInTaxonomy" message if taxonomy is empty', async () => {
useTaxonomyTagsData.mockReturnValue({
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
@@ -282,13 +295,15 @@ describe('<ContentTagsDropDownSelector />', () => {
});
const searchTerm = '';
const { getByText } = await getComponent({ ...data, searchTerm });
await act(async () => {
const { getByText } = await getComponent({ ...data, searchTerm });
await waitFor(() => {
expect(useTaxonomyTagsData).toHaveBeenCalledWith(data.taxonomyId, null, 1, searchTerm);
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
});
const message = 'No tags in this taxonomy yet';
expect(getByText(message)).toBeInTheDocument();
});
const message = 'No tags in this taxonomy yet';
expect(getByText(message)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,49 @@
// @ts-check
/* eslint-disable import/prefer-default-export */
import React from 'react';
/** @typedef {import("../data/types.mjs").TagsInTaxonomy} TagsInTaxonomy */
/** @typedef {import("../data/types.mjs").StagedTagData} StagedTagData */
/* istanbul ignore next */
export const ContentTagsDrawerContext = React.createContext({
stagedContentTags: /** @type{Record<number, StagedTagData[]>} */ ({}),
globalStagedContentTags: /** @type{Record<number, StagedTagData[]>} */ ({}),
globalStagedRemovedContentTags: /** @type{Record<number, string>} */ ({}),
addStagedContentTag: /** @type{(taxonomyId: number, addedTag: StagedTagData) => void} */ (() => {}),
removeStagedContentTag: /** @type{(taxonomyId: number, tagValue: string) => void} */ (() => {}),
removeGlobalStagedContentTag: /** @type{(taxonomyId: number, tagValue: string) => void} */ (() => {}),
addRemovedContentTag: /** @type{(taxonomyId: number, tagValue: string) => void} */ (() => {}),
deleteRemovedContentTag: /** @type{(taxonomyId: number, tagValue: string) => void} */ (() => {}),
setStagedTags: /** @type{(taxonomyId: number, tagsList: StagedTagData[]) => void} */ (() => {}),
setGlobalStagedContentTags: /** @type{Function} */ (() => {}),
commitGlobalStagedTags: /** @type{() => void} */ (() => {}),
commitGlobalStagedTagsStatus: /** @type{null|string} */ (null),
isContentDataLoaded: /** @type{boolean} */ (false),
isContentTaxonomyTagsLoaded: /** @type{boolean} */ (false),
isTaxonomyListLoaded: /** @type{boolean} */ (false),
contentName: /** @type{string} */ (''),
tagsByTaxonomy: /** @type{TagsInTaxonomy[]} */ ([]),
isEditMode: /** @type{boolean} */ (false),
toEditMode: /** @type{() => void} */ (() => {}),
toReadMode: /** @type{() => void} */ (() => {}),
collapsibleStates: /** @type{Record<number, boolean>} */ ({}),
openCollapsible: /** @type{(taxonomyId: number) => void} */ (() => {}),
closeCollapsible: /** @type{(taxonomyId: number) => void} */ (() => {}),
toastMessage: /** @type{string|undefined} */ (undefined),
showToastAfterSave: /** @type{() => void} */ (() => {}),
closeToast: /** @type{() => void} */ (() => {}),
setCollapsibleToInitalState: /** @type{() => void} */ (() => {}),
otherTaxonomies: /** @type{TagsInTaxonomy[]} */ ([]),
});
// This context has not been added to ContentTagsDrawerContext because it has been
// created one level higher to control the behavior of the Sheet that contatins the Drawer.
// This logic is not used in legacy edx-platform screens. But it can be separated if we keep
// the contexts separate.
// TODO We can join both contexts when the Drawer is no longer used on edx-platform
/* istanbul ignore next */
export const ContentTagsDrawerSheetContext = React.createContext({
blockingSheet: /** @type{boolean} */ (false),
setBlockingSheet: /** @type{Function} */ (() => {}),
});

View File

@@ -1,77 +0,0 @@
import React from 'react';
import type { TagsInTaxonomy, StagedTagData } from '../data/types';
export interface ContentTagsDrawerContextData {
stagedContentTags: Record<number, StagedTagData[]>;
globalStagedContentTags: Record<number, StagedTagData[]>;
globalStagedRemovedContentTags: Record<number, string>;
addStagedContentTag: (taxonomyId: number, addedTag: StagedTagData) => void;
removeStagedContentTag: (taxonomyId: number, tagValue: string) => void;
removeGlobalStagedContentTag: (taxonomyId: number, tagValue: string) => void;
addRemovedContentTag: (taxonomyId: number, tagValue: string) => void;
deleteRemovedContentTag: (taxonomyId: number, tagValue: string) => void;
setStagedTags: (taxonomyId: number, tagsList: StagedTagData[]) => void;
setGlobalStagedContentTags: Function;
commitGlobalStagedTags: () => void;
commitGlobalStagedTagsStatus: null | string;
isContentDataLoaded: boolean;
isContentTaxonomyTagsLoaded: boolean;
isTaxonomyListLoaded: boolean;
contentName: string;
tagsByTaxonomy: TagsInTaxonomy[];
isEditMode: boolean;
toEditMode: () => void;
toReadMode: () => void;
collapsibleStates: Record<number, boolean>;
openCollapsible: (taxonomyId: number) => void;
closeCollapsible: (taxonomyId: number) => void;
toastMessage: string | undefined;
showToastAfterSave: () => void;
closeToast: () => void;
setCollapsibleToInitalState: () => void;
otherTaxonomies: TagsInTaxonomy[];
}
/* istanbul ignore next */
export const ContentTagsDrawerContext = React.createContext<ContentTagsDrawerContextData>({
stagedContentTags: {},
globalStagedContentTags: {},
globalStagedRemovedContentTags: {},
addStagedContentTag: () => {},
removeStagedContentTag: () => {},
removeGlobalStagedContentTag: () => {},
addRemovedContentTag: () => {},
deleteRemovedContentTag: () => {},
setStagedTags: () => {},
setGlobalStagedContentTags: () => {},
commitGlobalStagedTags: () => {},
commitGlobalStagedTagsStatus: null,
isContentDataLoaded: false,
isContentTaxonomyTagsLoaded: false,
isTaxonomyListLoaded: false,
contentName: '',
tagsByTaxonomy: [],
isEditMode: false,
toEditMode: () => {},
toReadMode: () => {},
collapsibleStates: {},
openCollapsible: () => {},
closeCollapsible: () => {},
toastMessage: undefined,
showToastAfterSave: () => {},
closeToast: () => {},
setCollapsibleToInitalState: () => {},
otherTaxonomies: [],
});
// This context has not been added to ContentTagsDrawerContext because it has been
// created one level higher to control the behavior of the Sheet that contatins the Drawer.
// This logic is not used in legacy edx-platform screens. But it can be separated if we keep
// the contexts separate.
// TODO We can join both contexts when the Drawer is no longer used on edx-platform
/* istanbul ignore next */
export const ContentTagsDrawerSheetContext = React.createContext({
blockingSheet: false,
setBlockingSheet: (() => {}) as (blockingSheet: boolean) => void,
});

View File

@@ -38,7 +38,7 @@ export const getContentTaxonomyTagsCountApiUrl = (contentId) => new URL(`api/con
* Get all tags that belong to taxonomy.
* @param {number} taxonomyId The id of the taxonomy to fetch tags for
* @param {{page?: number, searchTerm?: string, parentTag?: string}} options
* @returns {Promise<import("../../taxonomy/data/types.js").TagListData>}
* @returns {Promise<import("../../taxonomy/tag-list/data/types.mjs").TagListData>}
*/
export async function getTaxonomyTagsData(taxonomyId, options = {}) {
const url = getTaxonomyTagsApiUrl(taxonomyId, options);
@@ -49,7 +49,7 @@ export async function getTaxonomyTagsData(taxonomyId, options = {}) {
/**
* Get the tags that are applied to the content object
* @param {string} contentId The id of the content object to fetch the applied tags for
* @returns {Promise<import("./types.js").ContentTaxonomyTagsData>}
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
*/
export async function getContentTaxonomyTagsData(contentId) {
const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsApiUrl(contentId));
@@ -70,13 +70,12 @@ export async function getContentTaxonomyTagsCount(contentId) {
}
/**
* Fetch meta data (eg: display_name) about the content object (unit/component)
* 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.js").ContentData>}
* @returns {Promise<import("./types.mjs").ContentData>}
*/
export async function getContentData(contentId) {
let url;
if (contentId.startsWith('lb:')) {
url = getLibraryContentDataApiUrl(contentId);
} else if (contentId.startsWith('course-v1:')) {
@@ -91,8 +90,8 @@ export async function getContentData(contentId) {
/**
* Update content object's applied tags
* @param {string} contentId The id of the content object (unit/component)
* @param {Promise<import("./types.js").UpdateTagsData[]>} tagsData The list of tags (values) to set on content object
* @returns {Promise<import("./types.js").ContentTaxonomyTagsData>}
* @param {Promise<import("./types.mjs").UpdateTagsData[]>} tagsData The list of tags (values) to set on content object
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
*/
export async function updateContentTaxonomyTags(contentId, tagsData) {
const url = getContentTaxonomyTagsApiUrl(contentId);

View File

@@ -1,380 +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.containerTagsId: 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.containerTagsId = 'lct:org:lib:unit:container_tags';
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,11 +14,9 @@ import {
updateContentTaxonomyTags,
getContentTaxonomyTagsCount,
} from './api';
import { libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
import { getLibraryId } from '../../generic/key-utils';
/** @typedef {import("../../taxonomy/data/types.js").TagListData} TagListData */
/** @typedef {import("../../taxonomy/data/types.js").TagData} TagData */
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagData} TagData */
/**
* Builds the query to get the taxonomy tags
@@ -112,13 +110,11 @@ export const useContentTaxonomyTagsData = (contentId) => (
/**
* Builds the query to get meta data about the content object
* @param {string} contentId The id of the content object (unit/component)
* @param {boolean} enabled Flag to enable/disable the query
*/
export const useContentData = (contentId, enabled) => (
export const useContentData = (contentId) => (
useQuery({
queryKey: ['contentData', contentId],
queryFn: enabled ? () => getContentData(contentId) : undefined,
enabled,
queryFn: () => getContentData(contentId),
})
);
@@ -128,7 +124,6 @@ export const useContentData = (contentId, enabled) => (
*/
export const useContentTaxonomyTagsUpdater = (contentId) => {
const queryClient = useQueryClient();
const unitIframe = window.frames['xblock-iframe'];
return useMutation({
/**
@@ -136,7 +131,7 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
* any,
* any,
* {
* tagsData: Promise<import("./types.js").UpdateTagsData[]>
* tagsData: Promise<import("./types.mjs").UpdateTagsData[]>
* }
* >}
*/
@@ -151,20 +146,11 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
contentPattern = contentId.replace(/\+type@.*$/, '*');
}
queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] });
if (contentId.startsWith('lb:') || contentId.startsWith('lib-collection:') || contentId.startsWith('lct:')) {
// 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 */
if (window.top != null) {
// Sends messages to the parent page if the drawer was opened
// from an iframe or the unit iframe within the course.
// This send messages to the parent page if the drawer is called from a iframe.
// Is used on Studio to update tags data and counts.
// In the future, when the Course Outline Page and Unit Page are integrated into this MFE,
// they should just use React Query to load the tag counts, and React Query will automatically
@@ -173,32 +159,26 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
// Sends content tags.
getContentTaxonomyTagsData(contentId).then((data) => {
const contentData = { contentId, ...data };
const message = {
type: 'authoring.events.tags.updated',
data: contentData,
const contentData = {
contentId,
...data,
};
const targetOrigin = getConfig().STUDIO_BASE_URL;
unitIframe?.postMessage(message, targetOrigin);
window.top?.postMessage(message, targetOrigin);
window.top?.postMessage(
{ type: 'authoring.events.tags.updated', data: contentData },
getConfig().STUDIO_BASE_URL,
);
});
// Sends tags count.
getContentTaxonomyTagsCount(contentId).then((count) => {
const contentData = { contentId, count };
const message = {
type: 'authoring.events.tags.count.updated',
data: contentData,
getContentTaxonomyTagsCount(contentId).then((data) => {
const contentData = {
contentId,
count: data,
};
const targetOrigin = getConfig().STUDIO_BASE_URL;
unitIframe?.postMessage(message, targetOrigin);
window.top?.postMessage(message, targetOrigin);
window.top?.postMessage(
{ type: 'authoring.events.tags.count.updated', data: contentData },
getConfig().STUDIO_BASE_URL,
);
});
}
},

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueries } from '@tanstack/react-query';
import { act, renderHook } from '@testing-library/react';
import { act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import {
useTaxonomyTagsData,
useContentTaxonomyTagsData,

View File

@@ -0,0 +1,101 @@
// @ts-check
/**
* @typedef {Object} Tag A tag that has been applied to some content.
* @property {string} value The value of the tag, also its ID. e.g. "Biology"
* @property {string[]} lineage The values of the tag and its parent(s) in the hierarchy
* @property {boolean} canChangeObjecttag
* @property {boolean} canDeleteObjecttag
*/
/**
* @typedef {Object} ContentTaxonomyTagData A list of the tags from one taxonomy that are applied to a content object.
* @property {string} name
* @property {number} taxonomyId
* @property {boolean} canTagObject
* @property {Tag[]} tags
* @property {string} exportId
*/
/**
* @typedef {Object} ContentTaxonomyTagsData A list of all the tags applied to some content object, grouped by taxonomy.
* @property {ContentTaxonomyTagData[]} taxonomies
*/
/**
* @typedef {Object} ContentActions
* @property {boolean} deleteable
* @property {boolean} draggable
* @property {boolean} childAddable
* @property {boolean} duplicable
*/
/**
* @typedef {Object} XBlockData
* @property {string} id
* @property {string} displayName
* @property {string} category
* @property {boolean} hasChildren
* @property {string} editedOn
* @property {boolean} published
* @property {string} publishedOn
* @property {string} studioUrl
* @property {boolean} releasedToStudents
* @property {string|null} releaseDate
* @property {string} visibilityState
* @property {boolean} hasExplicitStaffLock
* @property {string} start
* @property {boolean} graded
* @property {string} dueDate
* @property {string} due
* @property {string|null} relativeWeeksDue
* @property {string|null} format
* @property {boolean} hasChanges
* @property {ContentActions} actions
* @property {string} explanatoryMessage
* @property {string} showCorrectness
* @property {boolean} discussionEnabled
* @property {boolean} ancestorHasStaffLock
* @property {boolean} staffOnlyMessage
* @property {boolean} hasPartitionGroupComponents
*/
/**
* @typedef {Object} TagsInTaxonomy
* @property {boolean} allOrgs
* @property {boolean} allowFreeText
* @property {boolean} allowMultiple
* @property {boolean} canChangeTaxonomy
* @property {boolean} canDeleteTaxonomy
* @property {boolean} canTagObject
* @property {Tag[]} contentTags
* @property {string} description
* @property {boolean} enabled
* @property {string} exportId
* @property {number} id
* @property {string} name
* @property {boolean} systemDefined
* @property {number} tagsCount
* @property {boolean} visibleToAuthors
*/
/**
* @typedef {Object} CourseData
* @property {string} courseDisplayNameWithDefault
*/
/**
* @typedef {XBlockData | CourseData} ContentData
*/
/**
* @typedef {Object} UpdateTagsData
* @property {number} taxonomy
* @property {string[]} tags
*/
/**
* @typedef {Object} StagedTagData
* @property {string} value
* @property {string} label
*/

View File

@@ -1,81 +0,0 @@
import type { TaxonomyData } from '../../taxonomy/data/types';
/** A tag that has been applied to some content. */
export interface Tag {
/** The value of the tag, also its ID. e.g. "Biology" */
value: string;
/** The values of the tag and its parent(s) in the hierarchy */
lineage: string[];
canChangeObjecttag: boolean;
canDeleteObjecttag: boolean;
}
/** A list of the tags from one taxonomy that are applied to a content object. */
export interface ContentTaxonomyTagData {
name: string;
taxonomyId: number;
canTagObject: boolean;
tags: Tag[];
exportId: string;
}
/** A list of all the tags applied to some content object, grouped by taxonomy. */
export interface ContentTaxonomyTagsData {
taxonomies: ContentTaxonomyTagData[];
}
export interface ContentActions {
deleteable: boolean;
draggable: boolean;
childAddable: boolean;
duplicable: boolean;
}
export interface XBlockData {
id: string;
displayName: string;
category: string;
hasChildren: boolean;
editedOn: string;
published: boolean;
publishedOn: string;
studioUrl: string;
releasedToStudents: boolean;
releaseDate: string | null;
visibilityState: string;
hasExplicitStaffLock: boolean;
start: string;
graded: boolean;
dueDate: string;
due: string;
relativeWeeksDue: string | null;
format: string | null;
hasChanges: boolean;
actions: ContentActions;
explanatoryMessage: string;
showCorrectness: string;
discussionEnabled: boolean;
ancestorHasStaffLock: boolean;
staffOnlyMessage: boolean;
hasPartitionGroupComponents: boolean;
}
export interface TagsInTaxonomy extends TaxonomyData {
contentTags: Tag[];
}
export interface CourseData {
courseDisplayNameWithDefault: string;
}
export type ContentData = XBlockData | CourseData;
export interface UpdateTagsData {
taxonomy: number;
tags: string[];
}
export interface StagedTagData {
value: string;
label: string;
}

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line import/prefer-default-export
export { default as ContentTagsDrawer } from './ContentTagsDrawer';
// eslint-disable-next-line import/prefer-default-export
export { default as ContentTagsDrawerSheet } from './ContentTagsDrawerSheet';
export { useContentTaxonomyTagsData } from './data/apiHooks';

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

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

View File

@@ -1,2 +0,0 @@
export const extractOrgFromContentId = (contentId: string): string => contentId.split('+')[0].split(':')[1];
export const languageExportId = 'languages-v1';

View File

@@ -1,95 +1,70 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { ActionRow, Button, Icon } from '@openedx/paragon';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Button,
Hyperlink,
Icon,
} from '@openedx/paragon';
import { CheckCircle, RadioButtonUnchecked } from '@openedx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import { getWaffleFlags } from '../../data/selectors';
import messages from './messages';
const getUpdateLinks = (courseId, waffleFlags) => {
const baseUrl = getConfig().STUDIO_BASE_URL;
const isLegacyGradingUrl = !waffleFlags.useNewGradingPage;
const isLegacyCertificateUrl = !waffleFlags.useNewCertificatesPage;
const isLegacyCourseDatesUrl = !waffleFlags.useNewScheduleDetailsPage;
const isLegacyOutlineUrl = !waffleFlags.useNewCourseOutlinePage;
return {
welcomeMessage: `/course/${courseId}/course_info`,
gradingPolicy: isLegacyGradingUrl
? `${baseUrl}/settings/grading/${courseId}` : `/course/${courseId}/settings/grading`,
certificate: isLegacyCertificateUrl
? `${baseUrl}/certificates/${courseId}` : `/course/${courseId}/certificates`,
courseDates: isLegacyCourseDatesUrl
? `${baseUrl}/settings/details/${courseId}#schedule` : `/course/${courseId}/settings/details/#schedule`,
proctoringEmail: `${baseUrl}/pages-and-resources/proctoring/settings`,
outline: isLegacyOutlineUrl ? `${baseUrl}/course/${courseId}` : `/course/${courseId}`,
};
};
const ChecklistItemBody = ({
courseId,
checkId,
isCompleted,
}) => {
const intl = useIntl();
const waffleFlags = useSelector(getWaffleFlags);
const updateLinks = getUpdateLinks(courseId, waffleFlags);
return (
<ActionRow>
<div className="mr-3" id={`icon-${checkId}`} data-testid={`icon-${checkId}`}>
{isCompleted ? (
<Icon
data-testid="completed-icon"
src={CheckCircle}
className="text-success"
style={{ height: '32px', width: '32px' }}
screenReaderText={intl.formatMessage(messages.completedItemLabel)}
/>
) : (
<Icon
data-testid="uncompleted-icon"
src={RadioButtonUnchecked}
style={{ height: '32px', width: '32px' }}
screenReaderText={intl.formatMessage(messages.uncompletedItemLabel)}
/>
)}
</div>
<div>
<div>
<FormattedMessage {...messages[`${checkId}ShortDescription`]} />
</div>
<div className="small">
<FormattedMessage {...messages[`${checkId}LongDescription`]} />
</div>
</div>
<ActionRow.Spacer />
{updateLinks?.[checkId] && (
<Link
to={updateLinks[checkId]}
data-testid="update-link"
>
<Button size="sm">
<FormattedMessage {...messages.updateLinkLabel} />
</Button>
</Link>
updateLink,
// injected
intl,
}) => (
<ActionRow>
<div className="mr-3" id={`icon-${checkId}`} data-testid={`icon-${checkId}`}>
{isCompleted ? (
<Icon
data-testid="completed-icon"
src={CheckCircle}
className="text-success"
style={{ height: '32px', width: '32px' }}
screenReaderText={intl.formatMessage(messages.completedItemLabel)}
/>
) : (
<Icon
data-testid="uncompleted-icon"
src={RadioButtonUnchecked}
style={{ height: '32px', width: '32px' }}
screenReaderText={intl.formatMessage(messages.uncompletedItemLabel)}
/>
)}
</ActionRow>
);
};
</div>
<div>
<div>
<FormattedMessage {...messages[`${checkId}ShortDescription`]} />
</div>
<div className="small">
<FormattedMessage {...messages[`${checkId}LongDescription`]} />
</div>
</div>
<ActionRow.Spacer />
{updateLink && (
<Hyperlink destination={updateLink} data-testid="update-hyperlink">
<Button size="sm">
<FormattedMessage {...messages.updateLinkLabel} />
</Button>
</Hyperlink>
)}
</ActionRow>
);
ChecklistItemBody.defaultProps = {
updateLink: null,
};
ChecklistItemBody.propTypes = {
courseId: PropTypes.string.isRequired,
checkId: PropTypes.string.isRequired,
isCompleted: PropTypes.bool.isRequired,
updateLink: PropTypes.string,
// injected
intl: intlShape.isRequired,
};
export default ChecklistItemBody;
export default injectIntl(ChecklistItemBody);

View File

@@ -1,23 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { injectIntl, FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n';
import { Icon } from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { Hyperlink, Icon } from '@openedx/paragon';
import { ModeComment } from '@openedx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import { getWaffleFlags } from '../../data/selectors';
import messages from './messages';
const ChecklistItemComment = ({
courseId,
checkId,
outlineUrl,
data,
}) => {
const waffleFlags = useSelector(getWaffleFlags);
const getPathToCourseOutlinePage = (assignmentId) => (waffleFlags.useNewCourseOutlinePage
? `/course/${courseId}#${assignmentId}` : `${getConfig().STUDIO_BASE_URL}/course/${courseId}#${assignmentId}`);
const commentWrapper = (comment) => (
<div className="row m-0 mt-3 pt-3 border-top align-items-center" data-identifier="comment">
<div className="mr-4">
@@ -87,9 +79,10 @@ const ChecklistItemComment = ({
<ul className="assignment-list">
{gradedAssignmentsOutsideDateRange.map(assignment => (
<li className="assignment-list-item" key={assignment.id}>
<Link to={getPathToCourseOutlinePage(assignment.id)}>
{assignment.displayName}
</Link>
<Hyperlink
content={assignment.displayName}
destination={`${outlineUrl}#${assignment.id}`}
/>
</li>
))}
</ul>
@@ -104,7 +97,6 @@ const ChecklistItemComment = ({
};
ChecklistItemComment.propTypes = {
courseId: PropTypes.string.isRequired,
checkId: PropTypes.string.isRequired,
outlineUrl: PropTypes.string.isRequired,
data: PropTypes.oneOfType([

View File

@@ -10,11 +10,11 @@ import ChecklistItemComment from './ChecklistItemComment';
import { checklistItems } from './utils/courseChecklistData';
const ChecklistSection = ({
courseId,
dataHeading,
data,
idPrefix,
isLoading,
updateLinks,
}) => {
const dataList = checklistItems[idPrefix];
const getCompletionCountID = () => (`${idPrefix}-completion-count`);
@@ -37,6 +37,8 @@ const ChecklistSection = ({
{checks.map(check => {
const checkId = check.id;
const isCompleted = values[checkId];
const updateLink = updateLinks?.[checkId];
const outlineUrl = updateLinks.outline;
return (
<div
className={`bg-white border py-3 px-4 ${isCompleted && 'checklist-item-complete'}`}
@@ -44,9 +46,9 @@ const ChecklistSection = ({
data-testid={`checklist-item-${checkId}`}
key={checkId}
>
<ChecklistItemBody courseId={courseId} {...{ checkId, isCompleted }} />
<ChecklistItemBody {...{ checkId, isCompleted, updateLink }} />
<div data-testid={`comment-section-${checkId}`}>
<ChecklistItemComment {...{ courseId, checkId, data }} />
<ChecklistItemComment {...{ checkId, outlineUrl, data }} />
</div>
</div>
);
@@ -59,11 +61,11 @@ const ChecklistSection = ({
};
ChecklistSection.defaultProps = {
updateLinks: {},
data: {},
};
ChecklistSection.propTypes = {
courseId: PropTypes.string.isRequired,
dataHeading: PropTypes.string.isRequired,
data: PropTypes.oneOfType([
PropTypes.shape({
@@ -127,6 +129,14 @@ ChecklistSection.propTypes = {
]),
idPrefix: PropTypes.string.isRequired,
isLoading: PropTypes.bool.isRequired,
updateLinks: PropTypes.shape({
welcomeMessage: PropTypes.string,
gradingPolicy: PropTypes.string,
certificate: PropTypes.string,
courseDates: PropTypes.string,
proctoringEmail: PropTypes.string,
outline: PropTypes.string,
}),
};
export default injectIntl(ChecklistSection);

View File

@@ -1,49 +1,59 @@
import { camelCaseObject } from '@edx/frontend-platform';
/* eslint-disable */
import {
initializeMocks, render, screen, within,
} from '../../testUtils';
import { getApiWaffleFlagsUrl } from '../../data/api';
import { fetchWaffleFlags } from '../../data/thunks';
import { generateCourseLaunchData } from '../factories/mockApiResponses';
import { executeThunk } from '../../utils';
import { checklistItems } from './utils/courseChecklistData';
import messages from './messages';
render,
within,
screen,
} from '@testing-library/react';
import '@testing-library/jest-dom';
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import ChecklistSection from '.';
import initializeStore from '../../store';
import { initialState,generateCourseLaunchData } from '../factories/mockApiResponses';
import messages from './messages';
import ChecklistSection from './index';
import { checklistItems } from './utils/courseChecklistData';
import getUpdateLinks from '../utils';
const testData = camelCaseObject(generateCourseLaunchData());
const courseId = '123';
const defaultProps = {
courseId,
data: testData,
dataHeading: 'Test checklist',
idPrefix: 'launchChecklist',
updateLinks: getUpdateLinks('courseId'),
isLoading: false,
};
const testChecklistData = checklistItems[defaultProps.idPrefix];
const completedItemIds = ['welcomeMessage', 'courseDates'];
const completedItemIds = ['welcomeMessage', 'courseDates']
const renderComponent = (props) => {
render(<ChecklistSection {...props} />);
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<ChecklistSection {...props} />
</AppProvider>
</IntlProvider>,
);
};
let store;
describe('ChecklistSection', () => {
beforeEach(async () => {
const { axiosMock, reduxStore } = initializeMocks();
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {
useNewGradingPage: true,
useNewCertificatesPage: true,
useNewScheduleDetailsPage: true,
useNewCourseOutlinePage: true,
});
await executeThunk(fetchWaffleFlags(courseId), reduxStore.dispatch);
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
});
it('a heading using the dataHeading prop', () => {
@@ -54,7 +64,6 @@ describe('ChecklistSection', () => {
it('completion count text', () => {
renderComponent(defaultProps);
const completionText = `${completedItemIds.length}/6 completed`;
expect(screen.getByTestId('completion-subheader').textContent).toEqual(completionText);
});
@@ -113,7 +122,7 @@ describe('ChecklistSection', () => {
grades: {
...defaultProps.data.grades,
sumOfWeights: 1,
},
}
},
};
renderComponent(props);
@@ -145,7 +154,7 @@ describe('ChecklistSection', () => {
...defaultProps.data.assignments,
assignmentsWithDatesAfterEnd: [],
assignmentsWithOraDatesBeforeStart: [],
},
}
},
};
renderComponent(props);
@@ -174,52 +183,73 @@ describe('ChecklistSection', () => {
expect(assigmentLinks[1].textContent).toEqual('ORA subsection');
});
});
});
describe('Checklist Component', () => {
let checklistData;
let updateLinks;
testChecklistData.forEach((check) => {
describe(`check with id '${check.id}'`, () => {
let checkItem;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
renderComponent(defaultProps);
checklistData = testChecklistData.map((item) => ({
itemId: item.id,
checklistItem: screen.getAllByTestId(`checklist-item-${item.id}`),
icon: screen.getAllByTestId(`icon-${item.id}`),
shortDescription: messages[`${item.id}ShortDescription`].defaultMessage,
longDescription: messages[`${item.id}LongDescription`].defaultMessage,
}));
updateLinks = screen.getAllByTestId('update-link');
checkItem = screen.getAllByTestId(`checklist-item-${check.id}`);
});
it('should display the correct icons based on completion status', () => {
checklistData.forEach(({ itemId, icon }) => {
const { queryByTestId } = within(icon[0]);
if (completedItemIds.includes(itemId)) {
expect(queryByTestId('completed-icon')).not.toBeNull();
} else {
expect(queryByTestId('uncompleted-icon')).not.toBeNull();
}
});
it('renders', () => {
expect(checkItem).toHaveLength(1);
});
it('should display short and long descriptions for each checklist item', () => {
checklistData.forEach(({ checklistItem, shortDescription, longDescription }) => {
const { getByText } = within(checklistItem[0]);
it('has correct icon', () => {
const icon = screen.getAllByTestId(`icon-${check.id}`)
expect(getByText(shortDescription)).toBeVisible();
expect(getByText(longDescription)).toBeVisible();
});
expect(icon).toHaveLength(1);
const { queryByTestId } = within(icon[0]);
if (completedItemIds.includes(check.id)) {
expect(queryByTestId('completed-icon')).not.toBeNull();
} else {
expect(queryByTestId('uncompleted-icon')).not.toBeNull();
}
});
it('should have valid update links for each checklist item', () => {
checklistData.forEach(({ itemId }) => {
updateLinks.forEach((link) => {
expect(link).toHaveAttribute('href', updateLinks[itemId]);
it('has correct short description', () => {
const { getByText } = within(checkItem[0]);
const shortDescription = messages[`${check.id}ShortDescription`].defaultMessage;
expect(getByText(shortDescription)).toBeVisible();
});
it('has correct long description', () => {
const { getByText } = within(checkItem[0]);
const longDescription = messages[`${check.id}LongDescription`].defaultMessage;
expect(getByText(longDescription)).toBeVisible();
});
describe('has correct link', () => {
const links = getUpdateLinks('courseId')
const shouldShowLink = Object.keys(links).includes(check.id);
if (shouldShowLink) {
it('with a Hyperlink', () => {
const { getByRole, getByText } = within(checkItem[0]);
expect(getByText('Update')).toBeVisible();
expect(getByRole('link').href).toMatch(links[check.id]);
});
});
} else {
it('without a Hyperlink', () => {
const { queryByText } = within(checkItem[0]);
expect(queryByText('Update')).toBeNull();
});
}
});
});
});

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

@@ -13,7 +13,7 @@ import AriaLiveRegion from './AriaLiveRegion';
import { RequestStatus } from '../data/constants';
import ChecklistSection from './ChecklistSection';
import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
import getUpdateLinks from './utils';
const CourseChecklist = ({
courseId,
@@ -23,6 +23,7 @@ const CourseChecklist = ({
const dispatch = useDispatch();
const courseDetails = useModel('courseDetails', courseId);
const enableQuality = getConfig().ENABLE_CHECKLIST_QUALITY === 'true';
const updateLinks = getUpdateLinks(courseId);
useEffect(() => {
dispatch(fetchCourseLaunchQuery({ courseId }));
@@ -35,19 +36,10 @@ const CourseChecklist = ({
bestPracticeData,
} = useSelector(state => state.courseChecklist);
const { bestPracticeChecklistLoadingStatus, launchChecklistLoadingStatus, launchChecklistStatus } = loadingStatus;
const { bestPracticeChecklistLoadingStatus, launchChecklistLoadingStatus } = loadingStatus;
const isCourseLaunchChecklistLoading = bestPracticeChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
const isCourseBestPracticeChecklistLoading = launchChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
const isLoadingDenied = launchChecklistStatus === RequestStatus.DENIED;
if (isLoadingDenied) {
return (
<Container size="xl" className="course-unit px-4 mt-4">
<ConnectionErrorAlert />
</Container>
);
}
return (
<>
@@ -74,19 +66,19 @@ const CourseChecklist = ({
/>
<Stack gap={4}>
<ChecklistSection
courseId={courseId}
dataHeading={intl.formatMessage(messages.launchChecklistLabel)}
data={launchData}
idPrefix="launchChecklist"
isLoading={isCourseLaunchChecklistLoading}
updateLinks={updateLinks}
/>
{enableQuality && (
<ChecklistSection
courseId={courseId}
dataHeading={intl.formatMessage(messages.bestPracticesChecklistLabel)}
data={bestPracticeData}
idPrefix="bestPracticesChecklist"
isLoading={isCourseBestPracticeChecklistLoading}
updateLinks={updateLinks}
/>
)}
</Stack>

View File

@@ -149,20 +149,5 @@ describe('CourseChecklistPage', () => {
});
});
});
it('displays an alert and sets status to DENIED when API responds with 403', async () => {
const courseLaunchApiUrl = getCourseLaunchApiUrl({
courseId, gradedOnly: true, validateOras: true, all: true,
});
axiosMock.onGet(courseLaunchApiUrl).reply(403);
renderComponent();
await waitFor(() => {
const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
expect(launchChecklistStatus).toEqual(RequestStatus.DENIED);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
});
});

View File

@@ -24,11 +24,7 @@ export function fetchCourseLaunchQuery({
dispatch(fetchLaunchChecklistSuccess({ data }));
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
if (error.response && error.response.status === 403) {
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.DENIED }));
} else {
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.FAILED }));
}
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.FAILED }));
}
};
}

View File

@@ -0,0 +1,12 @@
import { getConfig } from '@edx/frontend-platform';
const getUpdateLinks = (courseId) => ({
welcomeMessage: `${getConfig().STUDIO_BASE_URL}/course_info/${courseId}`,
gradingPolicy: `${getConfig().STUDIO_BASE_URL}/settings/grading/${courseId}`,
certificate: `${getConfig().STUDIO_BASE_URL}/certificates/${courseId}`,
courseDates: `${getConfig().STUDIO_BASE_URL}/settings/details/${courseId}#schedule`,
proctoringEmail: 'pages-and-resources/proctoring/settings',
outline: `${getConfig().STUDIO_BASE_URL}/course/${courseId}`,
});
export default getUpdateLinks;

View File

@@ -1,244 +0,0 @@
import fetchMock from 'fetch-mock-jest';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter/types';
import { QueryClient } from '@tanstack/react-query';
import {
initializeMocks,
render,
screen,
waitFor,
within,
} from '../testUtils';
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
import { CourseLibraries } from './CourseLibraries';
import {
mockGetEntityLinks,
mockGetEntityLinksSummaryByDownstreamContext,
mockFetchIndexDocuments,
mockUseLibBlockMetadata,
} from './data/api.mocks';
import { libraryBlockChangesUrl } from '../course-unit/data/api';
import { type ToastActionData } from '../generic/toast-context';
mockContentSearchConfig.applyMock();
mockGetEntityLinks.applyMock();
mockGetEntityLinksSummaryByDownstreamContext.applyMock();
mockUseLibBlockMetadata.applyMock();
const searchParamsGetMock = jest.fn();
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
let queryClient: QueryClient;
jest.mock('../studio-home/hooks', () => ({
useStudioHome: () => ({
isLoadingPage: false,
isFailedLoadingPage: false,
librariesV2Enabled: true,
}),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
useSearchParams: () => [{
get: searchParamsGetMock,
getAll: () => [],
}],
}));
describe('<CourseLibraries />', () => {
beforeEach(() => {
initializeMocks();
fetchMock.mockReset();
mockFetchIndexDocuments.applyMock();
localStorage.clear();
searchParamsGetMock.mockReturnValue('all');
});
const renderCourseLibrariesPage = async (courseKey?: string) => {
const courseId = courseKey || mockGetEntityLinks.courseKey;
render(<CourseLibraries courseId={courseId} />);
};
it('shows the spinner before the query is complete', async () => {
// This mock will never return data (it loads forever):
await renderCourseLibrariesPage(mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading);
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
it('shows empty state when no links are present', async () => {
await renderCourseLibrariesPage(mockGetEntityLinks.courseKeyEmpty);
const emptyMsg = await screen.findByText('This course does not use any content from libraries.');
expect(emptyMsg).toBeInTheDocument();
});
it('shows alert when out of sync components are present', async () => {
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
userEvent.click(allTab);
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'5 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
expect(allTab).toHaveAttribute('aria-selected', 'true');
const reviewBtn = await screen.findByRole('button', { name: 'Review' });
userEvent.click(reviewBtn);
expect(allTab).toHaveAttribute('aria-selected', 'false');
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
expect(alert).not.toBeInTheDocument();
});
it('hide alert on dismiss', async () => {
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
userEvent.click(allTab);
expect(allTab).toHaveAttribute('aria-selected', 'true');
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'5 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
userEvent.click(dismissBtn);
expect(allTab).toHaveAttribute('aria-selected', 'true');
waitFor(() => expect(alert).not.toBeInTheDocument());
// review updates button
const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' });
userEvent.click(reviewActionBtn);
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
});
});
describe('<CourseLibraries ReviewTab />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
fetchMock.mockReset();
mockFetchIndexDocuments.applyMock();
localStorage.clear();
searchParamsGetMock.mockReturnValue('review');
queryClient = mocks.queryClient;
});
const renderCourseLibrariesReviewPage = async (courseKey?: string) => {
const courseId = courseKey || mockGetEntityLinks.courseKey;
render(<CourseLibraries courseId={courseId} />);
};
it('shows the spinner before the query is complete', async () => {
// This mock will never return data (it loads forever):
await renderCourseLibrariesReviewPage(mockGetEntityLinks.courseKeyLoading);
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
it('shows empty state when no readyToSync links are present', async () => {
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKeyUpToDate);
const emptyMsg = await screen.findByText('All components are up to date');
expect(emptyMsg).toBeInTheDocument();
});
it('shows all readyToSync links', async () => {
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
expect(updateBtns.length).toEqual(5);
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
expect(ignoreBtns.length).toEqual(5);
});
it('update changes works', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
expect(updateBtns.length).toEqual(5);
userEvent.click(updateBtns[0]);
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
});
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith('Success! "Dropdown" is updated');
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});
it('update changes works in preview modal', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(5);
userEvent.click(previewBtns[0]);
const dialog = await screen.findByRole('dialog');
const confirmBtn = await within(dialog).findByRole('button', { name: 'Accept changes' });
userEvent.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
});
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith('Success! "Dropdown" is updated');
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});
it('ignore change works', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
expect(ignoreBtns.length).toEqual(5);
// Show confirmation modal on clicking ignore.
userEvent.click(ignoreBtns[0]);
const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' });
expect(dialog).toBeInTheDocument();
const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' });
userEvent.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.delete.length).toEqual(1);
});
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith(
'"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
);
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});
it('ignore change works in preview', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(5);
userEvent.click(previewBtns[0]);
const previewDialog = await screen.findByRole('dialog');
const ignoreBtn = await within(previewDialog).findByRole('button', { name: 'Ignore changes' });
userEvent.click(ignoreBtn);
// Show confirmation modal on clicking ignore.
const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' });
expect(dialog).toBeInTheDocument();
const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' });
userEvent.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.delete.length).toEqual(1);
});
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith(
'"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
);
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});
});

View File

@@ -1,247 +0,0 @@
import React, {
useCallback, useEffect, useMemo, useState,
} from 'react';
import { Helmet } from 'react-helmet';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Alert,
ActionRow,
Button,
Card,
Container,
Hyperlink,
Icon,
Stack,
Tab,
Tabs,
} from '@openedx/paragon';
import {
Cached, CheckCircle, Launch, Loop,
} from '@openedx/paragon/icons';
import sumBy from 'lodash/sumBy';
import { useSearchParams } from 'react-router-dom';
import getPageHeadTitle from '../generic/utils';
import { useModel } from '../generic/model-store';
import messages from './messages';
import SubHeader from '../generic/sub-header/SubHeader';
import { useEntityLinksSummaryByDownstreamContext } from './data/apiHooks';
import type { PublishableEntityLinkSummary } from './data/api';
import Loading from '../generic/Loading';
import { useStudioHome } from '../studio-home/hooks';
import NewsstandIcon from '../generic/NewsstandIcon';
import ReviewTabContent from './ReviewTabContent';
import { OutOfSyncAlert } from './OutOfSyncAlert';
interface Props {
courseId: string;
}
interface LibraryCardProps {
linkSummary: PublishableEntityLinkSummary;
}
export enum CourseLibraryTabs {
all = 'all',
review = 'review',
}
const LibraryCard = ({ linkSummary }: LibraryCardProps) => {
const intl = useIntl();
return (
<Card className="my-3 border-light-500 border shadow-none">
<Card.Header
title={(
<Stack direction="horizontal" gap={2}>
<Icon src={NewsstandIcon} />
{linkSummary.upstreamContextTitle}
</Stack>
)}
actions={(
<ActionRow>
<Button
destination={`${getConfig().PUBLIC_PATH}library/${linkSummary.upstreamContextKey}`}
target="_blank"
className="border border-light-300"
variant="tertiary"
as={Hyperlink}
size="sm"
showLaunchIcon={false}
iconAfter={Launch}
>
View Library
</Button>
</ActionRow>
)}
size="sm"
/>
<Card.Section>
<Stack
direction="horizontal"
gap={4}
className="x-small"
>
<span>
{intl.formatMessage(messages.totalComponentLabel, { totalComponents: linkSummary.totalCount })}
</span>
{linkSummary.readyToSyncCount > 0 && (
<Stack direction="horizontal" gap={1}>
<Icon src={Loop} size="xs" />
<span>
{intl.formatMessage(messages.outOfSyncCountLabel, { outOfSyncCount: linkSummary.readyToSyncCount })}
</span>
</Stack>
)}
</Stack>
</Card.Section>
</Card>
);
};
export const CourseLibraries: React.FC<Props> = ({ courseId }) => {
const intl = useIntl();
const courseDetails = useModel('courseDetails', courseId);
const [searchParams] = useSearchParams();
const [tabKey, setTabKey] = useState<CourseLibraryTabs>(
() => searchParams.get('tab') as CourseLibraryTabs,
);
const [showReviewAlert, setShowReviewAlert] = useState(false);
const { data: libraries, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
const outOfSyncCount = useMemo(() => sumBy(libraries, (lib) => lib.readyToSyncCount), [libraries]);
const {
isLoadingPage: isLoadingStudioHome,
isFailedLoadingPage: isFailedLoadingStudioHome,
librariesV2Enabled,
} = useStudioHome();
const onAlertReview = () => {
setTabKey(CourseLibraryTabs.review);
};
const tabChange = useCallback((selectedTab: CourseLibraryTabs) => {
setTabKey(selectedTab);
}, []);
useEffect(() => {
setTabKey((prev) => {
if (outOfSyncCount > 0) {
return CourseLibraryTabs.review;
}
if (prev) {
return prev;
}
/* istanbul ignore next */
return CourseLibraryTabs.all;
});
}, [outOfSyncCount]);
const renderLibrariesTabContent = useCallback(() => {
if (isLoading) {
return <Loading />;
}
if (libraries?.length === 0) {
return <small><FormattedMessage {...messages.homeTabDescriptionEmpty} /></small>;
}
return (
<>
<small><FormattedMessage {...messages.homeTabDescription} /></small>
{libraries?.map((library) => (
<LibraryCard
linkSummary={library}
key={library.upstreamContextKey}
/>
))}
</>
);
}, [libraries, isLoading]);
const renderReviewTabContent = useCallback(() => {
if (isLoading) {
return <Loading />;
}
if (tabKey !== CourseLibraryTabs.review) {
return null;
}
if (!outOfSyncCount || outOfSyncCount === 0) {
return (
<Stack direction="horizontal" gap={2}>
<Icon src={CheckCircle} size="xs" />
<small>
<FormattedMessage {...messages.reviewTabDescriptionEmpty} />
</small>
</Stack>
);
}
return <ReviewTabContent courseId={courseId} />;
}, [outOfSyncCount, isLoading, tabKey]);
if (!isLoadingStudioHome && (!librariesV2Enabled || isFailedLoadingStudioHome)) {
return (
<Alert variant="danger">
{intl.formatMessage(messages.librariesV2DisabledError)}
</Alert>
);
}
return (
<>
<Helmet>
<title>
{getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle))}
</title>
</Helmet>
<Container size="xl" className="px-4 pt-4 mt-3">
<OutOfSyncAlert
courseId={courseId}
onReview={onAlertReview}
showAlert={showReviewAlert && tabKey === CourseLibraryTabs.all}
setShowAlert={setShowReviewAlert}
/>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={!showReviewAlert && outOfSyncCount > 0 && tabKey === CourseLibraryTabs.all && (
<Button
variant="primary"
onClick={onAlertReview}
iconBefore={Cached}
>
{intl.formatMessage(messages.reviewUpdatesBtn)}
</Button>
)}
hideBorder
/>
<section className="mb-4">
<Tabs
id="course-library-tabs"
activeKey={tabKey}
onSelect={tabChange}
>
<Tab
eventKey={CourseLibraryTabs.all}
title={intl.formatMessage(messages.homeTabTitle)}
className="px-2 mt-3"
>
{renderLibrariesTabContent()}
</Tab>
<Tab
eventKey={CourseLibraryTabs.review}
title={(
<Stack direction="horizontal" gap={1}>
<Icon src={Loop} />
{intl.formatMessage(messages.reviewTabTitle)}
</Stack>
)}
notification={outOfSyncCount}
className="px-2 mt-3"
>
{renderReviewTabContent()}
</Tab>
</Tabs>
</section>
</Container>
</>
);
};

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