Compare commits
2 Commits
test_hyper
...
abdullahwa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb5f51e47 | ||
|
|
6a115797e6 |
11
.env
11
.env
@@ -1,4 +1,3 @@
|
|||||||
APP_ID='authoring'
|
|
||||||
NODE_ENV='production'
|
NODE_ENV='production'
|
||||||
ACCESS_TOKEN_COOKIE_NAME=''
|
ACCESS_TOKEN_COOKIE_NAME=''
|
||||||
BASE_URL=''
|
BASE_URL=''
|
||||||
@@ -31,19 +30,15 @@ USER_INFO_COOKIE_NAME=''
|
|||||||
ENABLE_ACCESSIBILITY_PAGE=false
|
ENABLE_ACCESSIBILITY_PAGE=false
|
||||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||||
ENABLE_TEAM_TYPE_SETTING=false
|
ENABLE_TEAM_TYPE_SETTING=false
|
||||||
|
ENABLE_NEW_EDITOR_PAGES=true
|
||||||
ENABLE_UNIT_PAGE=false
|
ENABLE_UNIT_PAGE=false
|
||||||
ENABLE_ASSETS_PAGE=false
|
ENABLE_ASSETS_PAGE=false
|
||||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
ENABLE_TAGGING_TAXONOMY_PAGES=false
|
||||||
ENABLE_CERTIFICATE_PAGE=true
|
|
||||||
BBB_LEARN_MORE_URL=''
|
BBB_LEARN_MORE_URL=''
|
||||||
HOTJAR_APP_ID=''
|
HOTJAR_APP_ID=''
|
||||||
HOTJAR_VERSION=6
|
HOTJAR_VERSION=6
|
||||||
HOTJAR_DEBUG=false
|
HOTJAR_DEBUG=false
|
||||||
INVITE_STUDENTS_EMAIL_TO=''
|
INVITE_STUDENTS_EMAIL_TO=''
|
||||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
AI_TRANSLATIONS_BASE_URL=''
|
||||||
ENABLE_CHECKLIST_QUALITY=''
|
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"
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
APP_ID='authoring'
|
|
||||||
NODE_ENV='development'
|
NODE_ENV='development'
|
||||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||||
BASE_URL='http://localhost:2001'
|
BASE_URL='http://localhost:2001'
|
||||||
@@ -33,19 +32,15 @@ USER_INFO_COOKIE_NAME='edx-user-info'
|
|||||||
ENABLE_ACCESSIBILITY_PAGE=false
|
ENABLE_ACCESSIBILITY_PAGE=false
|
||||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||||
ENABLE_TEAM_TYPE_SETTING=false
|
ENABLE_TEAM_TYPE_SETTING=false
|
||||||
|
ENABLE_NEW_EDITOR_PAGES=true
|
||||||
ENABLE_UNIT_PAGE=false
|
ENABLE_UNIT_PAGE=false
|
||||||
ENABLE_ASSETS_PAGE=false
|
ENABLE_ASSETS_PAGE=false
|
||||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||||
ENABLE_CERTIFICATE_PAGE=true
|
|
||||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
|
|
||||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||||
BBB_LEARN_MORE_URL=''
|
BBB_LEARN_MORE_URL=''
|
||||||
HOTJAR_APP_ID=''
|
HOTJAR_APP_ID=''
|
||||||
HOTJAR_VERSION=6
|
HOTJAR_VERSION=6
|
||||||
HOTJAR_DEBUG=true
|
HOTJAR_DEBUG=true
|
||||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'
|
||||||
ENABLE_CHECKLIST_QUALITY=true
|
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"
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
APP_ID='authoring'
|
|
||||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||||
BASE_URL='http://localhost:2001'
|
BASE_URL='http://localhost:2001'
|
||||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||||
@@ -29,16 +28,11 @@ SUPPORT_URL='https://support.edx.org'
|
|||||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||||
ENABLE_TEAM_TYPE_SETTING=false
|
ENABLE_TEAM_TYPE_SETTING=false
|
||||||
|
ENABLE_NEW_EDITOR_PAGES=true
|
||||||
ENABLE_UNIT_PAGE=true
|
ENABLE_UNIT_PAGE=true
|
||||||
ENABLE_ASSETS_PAGE=false
|
ENABLE_ASSETS_PAGE=false
|
||||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||||
ENABLE_CERTIFICATE_PAGE=true
|
|
||||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||||
BBB_LEARN_MORE_URL=''
|
BBB_LEARN_MORE_URL=''
|
||||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
|
||||||
ENABLE_CHECKLIST_QUALITY=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"
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
coverage/*
|
coverage/*
|
||||||
dist/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
jest.config.js
|
jest.config.js
|
||||||
env.config.jsx
|
|
||||||
example.env.config.jsx
|
|
||||||
@@ -11,9 +11,8 @@ module.exports = createConfig(
|
|||||||
}],
|
}],
|
||||||
'template-curly-spacing': 'off',
|
'template-curly-spacing': 'off',
|
||||||
'react-hooks/exhaustive-deps': 'off',
|
'react-hooks/exhaustive-deps': 'off',
|
||||||
|
indent: ['error', 2],
|
||||||
'no-restricted-exports': 'off',
|
'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: {
|
settings: {
|
||||||
// Import URLs should be resolved using aliases
|
// Import URLs should be resolved using aliases
|
||||||
|
|||||||
7
.github/dependabot.yml
vendored
7
.github/dependabot.yml
vendored
@@ -1,7 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
# Adding new check for github-actions
|
|
||||||
- package-ecosystem: "github-actions"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
26
.github/workflows/validate.yml
vendored
26
.github/workflows/validate.yml
vendored
@@ -9,29 +9,15 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v4
|
- name: Setup Nodejs Env
|
||||||
|
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version-file: '.nvmrc'
|
node-version: ${{ env.NODE_VER }}
|
||||||
- run: make validate.ci
|
- 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
|
- name: Upload coverage
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v3
|
||||||
with:
|
with:
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,7 +1,6 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
.idea
|
.idea
|
||||||
.run
|
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
coverage
|
coverage
|
||||||
@@ -27,6 +26,3 @@ temp/babel-plugin-react-intl
|
|||||||
|
|
||||||
# Messages .json files fetched by atlas
|
# Messages .json files fetched by atlas
|
||||||
src/i18n/messages/
|
src/i18n/messages/
|
||||||
|
|
||||||
# environment js config
|
|
||||||
env.config.jsx
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
"scss/at-rule-no-unknown": true,
|
"scss/at-rule-no-unknown": true,
|
||||||
"scss/at-import-partial-extension": null,
|
"scss/at-import-partial-extension": null,
|
||||||
"scss/comment-no-empty": null,
|
"scss/comment-no-empty": null,
|
||||||
"import-notation": "string",
|
|
||||||
"property-no-unknown": [true, {
|
"property-no-unknown": [true, {
|
||||||
"ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"]
|
"ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"]
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -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
|
* @openedx/2u-tnl
|
||||||
|
|||||||
5
Makefile
5
Makefile
@@ -35,12 +35,13 @@ pull_translations:
|
|||||||
cd src/i18n/messages \
|
cd src/i18n/messages \
|
||||||
&& atlas pull $(ATLAS_OPTIONS) \
|
&& atlas pull $(ATLAS_OPTIONS) \
|
||||||
translations/frontend-component-ai-translations/src/i18n/messages:frontend-component-ai-translations \
|
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/frontend-platform/src/i18n/messages:frontend-platform \
|
||||||
translations/paragon/src/i18n/messages:paragon \
|
translations/paragon/src/i18n/messages:paragon \
|
||||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||||
translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring
|
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.
|
# This target is used by Travis.
|
||||||
validate-no-uncommitted-package-lock-changes:
|
validate-no-uncommitted-package-lock-changes:
|
||||||
@@ -53,7 +54,7 @@ validate:
|
|||||||
npm run i18n_extract
|
npm run i18n_extract
|
||||||
npm run lint -- --max-warnings 0
|
npm run lint -- --max-warnings 0
|
||||||
npm run types
|
npm run types
|
||||||
npm run test:ci
|
npm run test
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
.PHONY: validate.ci
|
.PHONY: validate.ci
|
||||||
|
|||||||
132
README.rst
132
README.rst
@@ -1,5 +1,5 @@
|
|||||||
frontend-app-authoring
|
frontend-app-course-authoring
|
||||||
######################
|
#############################
|
||||||
|
|
||||||
|license-badge| |status-badge| |codecov-badge|
|
|license-badge| |status-badge| |codecov-badge|
|
||||||
|
|
||||||
@@ -7,9 +7,9 @@ frontend-app-authoring
|
|||||||
Purpose
|
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
|
Getting Started
|
||||||
@@ -18,87 +18,51 @@ Getting Started
|
|||||||
Prerequisites
|
Prerequisites
|
||||||
=============
|
=============
|
||||||
|
|
||||||
`Tutor`_ is currently recommended as a development environment for the Authoring
|
The `devstack`_ is currently recommended as a development environment for your
|
||||||
MFE. Most likely, it already has this MFE configured; however, you'll need to
|
new MFE. If you start it with ``make dev.up.lms`` that should give you
|
||||||
make some changes in order to run it in development mode. You can refer
|
everything you need as a companion to this frontend.
|
||||||
to the `relevant tutor-mfe documentation`_ for details, or follow the quick
|
|
||||||
guide below.
|
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
|
.. _Tutor: https://github.com/overhangio/tutor
|
||||||
|
|
||||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
.. _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.
|
2. Use node v18.x.
|
||||||
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>`_.
|
|
||||||
|
|
||||||
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
|
3. Install npm dependencies:
|
||||||
development mode, and it should be excluded from the ``mfe`` container that
|
|
||||||
otherwise runs every MFE. Run this:
|
|
||||||
|
|
||||||
.. 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,
|
4. Start the dev server:
|
||||||
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:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
``npm start``
|
||||||
|
|
||||||
tutor dev start lms cms mfe
|
|
||||||
|
|
||||||
Startup
|
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
|
||||||
=======
|
or whatever port you setup.
|
||||||
|
|
||||||
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>`__.
|
|
||||||
|
|
||||||
|
|
||||||
Features
|
Features
|
||||||
@@ -176,11 +140,22 @@ Requirements
|
|||||||
* ``new_core_editors.use_new_video_editor``: must be enabled for the new Video Xblock editor to be used in Studio
|
* ``new_core_editors.use_new_video_editor``: must be enabled for the new Video Xblock editor to be used in Studio
|
||||||
* ``new_core_editors.use_new_problem_editor``: must be enabled for the new Problem Xblock editor to be used in Studio
|
* ``new_core_editors.use_new_problem_editor``: must be enabled for the new Problem Xblock editor to be used in Studio
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
-------------
|
||||||
|
|
||||||
|
In additional to the standard settings, the following local configuration item is required:
|
||||||
|
|
||||||
|
* ``ENABLE_NEW_EDITOR_PAGES``: must be enabled in order to actually present the new XBlock editors (on by default)
|
||||||
|
|
||||||
Feature Description
|
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.
|
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
|
Feature: New Proctoring Exams View
|
||||||
==================================
|
==================================
|
||||||
|
|
||||||
@@ -292,30 +267,13 @@ Configuration
|
|||||||
|
|
||||||
In additional to the standard settings, the following local configuration items are required:
|
In additional to the standard settings, the following local configuration items are required:
|
||||||
|
|
||||||
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled (which it is by default) in order to actually enable/show the new
|
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled in order to actually present the new Tagging/Taxonomy pages.
|
||||||
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
|
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:
|
If your devstack includes the default Demo course, you can visit the following URLs to see content:
|
||||||
@@ -344,8 +302,8 @@ The production build is created with ``npm run build``.
|
|||||||
:target: https://travis-ci.com/edx/frontend-app-course-authoring
|
: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
|
.. |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
|
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
|
||||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-authoring.svg
|
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
|
||||||
:target: @edx/frontend-app-authoring
|
:target: @edx/frontend-app-course-authoring
|
||||||
|
|
||||||
Internationalization
|
Internationalization
|
||||||
====================
|
====================
|
||||||
|
|||||||
@@ -4,15 +4,14 @@
|
|||||||
apiVersion: backstage.io/v1alpha1
|
apiVersion: backstage.io/v1alpha1
|
||||||
kind: Component
|
kind: Component
|
||||||
metadata:
|
metadata:
|
||||||
name: 'frontend-app-authoring'
|
name: 'frontend-app-course-authoring'
|
||||||
description: "The frontend (MFE) for Open edX Authoring (aka Studio)"
|
description: "The frontend (MFE) for Open edX Course Authoring (aka Studio)"
|
||||||
links:
|
links:
|
||||||
- url: "https://github.com/openedx/frontend-app-authoring"
|
- url: "https://github.com/openedx/frontend-app-course-authoring"
|
||||||
title: "Frontend app authoring"
|
title: "Frontend app course authoring"
|
||||||
icon: "Web"
|
icon: "Web"
|
||||||
annotations:
|
annotations:
|
||||||
openedx.org/arch-interest-groups: ""
|
openedx.org/arch-interest-groups: ""
|
||||||
openedx.org/release: "master"
|
|
||||||
spec:
|
spec:
|
||||||
owner: group:2u-tnl
|
owner: group:2u-tnl
|
||||||
type: 'website'
|
type: 'website'
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import WholeCourseTranslation from '@edx/course-app-translation-plugin';
|
|
||||||
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
|
|
||||||
|
|
||||||
// Load environment variables from .env file
|
|
||||||
const config = {
|
|
||||||
...process.env,
|
|
||||||
pluginSlots: {
|
|
||||||
additional_course_plugin: {
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
op: PLUGIN_OPERATIONS.Insert,
|
|
||||||
widget: {
|
|
||||||
id: 'whole-course-translation-plugin',
|
|
||||||
type: DIRECT_PLUGIN,
|
|
||||||
priority: 1,
|
|
||||||
RenderWidget: WholeCourseTranslation,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
11
openedx.yaml
Normal file
11
openedx.yaml
Normal 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
|
||||||
20578
package-lock.json
generated
20578
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
112
package.json
112
package.json
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@edx/frontend-app-authoring",
|
"name": "@edx/frontend-app-course-authoring",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Frontend application template",
|
"description": "Frontend application template",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/openedx/frontend-app-authoring.git"
|
"url": "git+https://github.com/openedx/frontend-app-course-authoring.git"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"extends @edx/browserslist-config"
|
"extends @edx/browserslist-config"
|
||||||
@@ -12,43 +12,48 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "fedx-scripts webpack",
|
"build": "fedx-scripts webpack",
|
||||||
"i18n_extract": "fedx-scripts formatjs extract",
|
"i18n_extract": "fedx-scripts formatjs extract",
|
||||||
"stylelint": "stylelint \"plugins/**/*.scss\" \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
|
"stylelint": "stylelint \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
|
||||||
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
|
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
|
||||||
"lint:fix": "npm run stylelint -- --fix && fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
|
"lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||||
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
|
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
|
||||||
"start": "fedx-scripts webpack-dev-server --progress",
|
"start": "fedx-scripts webpack-dev-server --progress",
|
||||||
"start:with-theme": "paragon install-theme && npm start && npm install",
|
"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": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
|
||||||
"test:ci": "TZ=UTC fedx-scripts jest --silent --coverage --passWithNoTests",
|
|
||||||
"types": "tsc --noEmit"
|
"types": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
"husky": {
|
||||||
|
"hooks": {
|
||||||
|
"pre-commit": "npm run lint"
|
||||||
|
}
|
||||||
|
},
|
||||||
"author": "edX",
|
"author": "edX",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"homepage": "https://github.com/openedx/frontend-app-authoring#readme",
|
"homepage": "https://github.com/openedx/frontend-app-course-authoring#readme",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/openedx/frontend-app-authoring/issues"
|
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-html": "^6.0.0",
|
"@datadog/browser-rum": "^5.13.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/core": "^6.1.0",
|
||||||
"@dnd-kit/modifiers": "^7.0.0",
|
"@dnd-kit/modifiers": "^7.0.0",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
|
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||||
"@edx/browserslist-config": "1.2.0",
|
"@edx/frontend-component-ai-translations": "^2.0.0",
|
||||||
"@edx/frontend-component-footer": "^14.3.0",
|
"@edx/frontend-component-footer": "^13.0.2",
|
||||||
"@edx/frontend-component-header": "^6.2.0",
|
"@edx/frontend-component-header": "^5.0.2",
|
||||||
"@edx/frontend-enterprise-hotjar": "^7.2.0",
|
"@edx/frontend-enterprise-hotjar": "^2.0.0",
|
||||||
"@edx/frontend-platform": "^8.3.1",
|
"@edx/frontend-lib-content-components": "^2.1.4",
|
||||||
|
"@edx/frontend-platform": "7.0.1",
|
||||||
"@edx/openedx-atlas": "^0.6.0",
|
"@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",
|
||||||
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
||||||
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||||
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||||
@@ -59,66 +64,59 @@
|
|||||||
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
|
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
|
||||||
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
|
"@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-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
|
||||||
"@openedx/frontend-build": "^14.3.3",
|
"@openedx/paragon": "^21.5.7",
|
||||||
"@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",
|
|
||||||
"@reduxjs/toolkit": "1.9.7",
|
"@reduxjs/toolkit": "1.9.7",
|
||||||
"@tanstack/react-query": "4.36.1",
|
"@tanstack/react-query": "4.36.1",
|
||||||
"@tinymce/tinymce-react": "^3.14.0",
|
"broadcast-channel": "^7.0.0",
|
||||||
"classnames": "2.5.1",
|
"classnames": "2.2.6",
|
||||||
"codemirror": "^6.0.0",
|
"core-js": "3.8.1",
|
||||||
"email-validator": "2.0.4",
|
"email-validator": "2.0.4",
|
||||||
"fast-xml-parser": "^4.0.10",
|
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"formik": "2.4.6",
|
"formik": "2.2.6",
|
||||||
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
|
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"meilisearch": "^0.41.0",
|
"moment": "2.29.4",
|
||||||
"moment": "2.30.1",
|
"prop-types": "15.7.2",
|
||||||
"moment-shortformat": "^2.1.0",
|
"react": "17.0.2",
|
||||||
"npm": "^10.8.1",
|
|
||||||
"prop-types": "^15.8.1",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-datepicker": "^4.13.0",
|
"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-helmet": "^6.1.0",
|
||||||
"react-onclickoutside": "^6.13.0",
|
|
||||||
"react-redux": "7.2.9",
|
"react-redux": "7.2.9",
|
||||||
"react-responsive": "9.0.2",
|
"react-responsive": "9.0.2",
|
||||||
"react-router": "6.27.0",
|
"react-router": "6.16.0",
|
||||||
"react-router-dom": "6.27.0",
|
"react-router-dom": "6.16.0",
|
||||||
"react-select": "5.8.0",
|
"react-select": "5.8.0",
|
||||||
"react-textarea-autosize": "^8.5.3",
|
"react-textarea-autosize": "^8.4.1",
|
||||||
"react-transition-group": "4.4.5",
|
"react-transition-group": "4.4.5",
|
||||||
"redux": "4.0.5",
|
"redux": "4.0.5",
|
||||||
"redux-logger": "^3.0.6",
|
"regenerator-runtime": "0.13.7",
|
||||||
"redux-thunk": "^2.4.1",
|
|
||||||
"reselect": "^4.1.5",
|
|
||||||
"start": "^5.1.0",
|
|
||||||
"tinymce": "^5.10.4",
|
|
||||||
"universal-cookie": "^4.0.4",
|
"universal-cookie": "^4.0.4",
|
||||||
"uuid": "^3.4.0",
|
"uuid": "^3.4.0",
|
||||||
"xmlchecker": "^0.1.0",
|
|
||||||
"yup": "0.31.1"
|
"yup": "0.31.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edx/react-unit-test-utils": "^4.0.0",
|
"@edx/browserslist-config": "1.2.0",
|
||||||
"@edx/stylelint-config-edx": "2.3.3",
|
"@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",
|
"@edx/typescript-config": "^1.0.1",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@openedx/frontend-build": "13.0.27",
|
||||||
"@testing-library/react": "^16.2.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",
|
"@testing-library/user-event": "^13.2.1",
|
||||||
"@types/lodash": "^4.17.7",
|
"axios": "^0.27.2",
|
||||||
"axios-mock-adapter": "1.22.0",
|
"axios-mock-adapter": "1.22.0",
|
||||||
"eslint-import-resolver-webpack": "^0.13.8",
|
"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-canvas-mock": "^2.5.2",
|
||||||
"jest-expect-message": "^1.1.3",
|
"jest-expect-message": "^1.1.3",
|
||||||
"react-test-renderer": "^18.3.1",
|
"react-test-renderer": "17.0.2",
|
||||||
"redux-mock-store": "^1.5.4"
|
"reactifex": "1.1.1",
|
||||||
|
"ts-loader": "^9.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"decode-uri-component": ">=0.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Calculator configuration for courses using it",
|
"description": "Calculator configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"prop-types": "*",
|
"prop-types": "*",
|
||||||
"react": "*"
|
"react": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "edxnotes configuration for courses using it",
|
"description": "edxnotes configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"prop-types": "*",
|
"prop-types": "*",
|
||||||
"react": "*"
|
"react": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Learning Assistant configuration for courses using it",
|
"description": "Learning Assistant configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"prop-types": "*",
|
"prop-types": "*",
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"yup": "*"
|
"yup": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { useDispatch, useSelector } from 'react-redux';
|
|||||||
import { camelCase } from 'lodash';
|
import { camelCase } from 'lodash';
|
||||||
import { Icon } from '@openedx/paragon';
|
import { Icon } from '@openedx/paragon';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
import { SelectableBox } from '@edx/frontend-lib-content-components';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||||
import Loading from 'CourseAuthoring/generic/Loading';
|
import Loading from 'CourseAuthoring/generic/Loading';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
import { bbbPlanTypes } from '../constants';
|
import { bbbPlanTypes } from '../constants';
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Live course configuration for courses using it",
|
"description": "Live course configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
|
"@edx/frontend-lib-content-components": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"@reduxjs/toolkit": "*",
|
"@reduxjs/toolkit": "*",
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
"yup": "*"
|
"yup": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,176 +1,69 @@
|
|||||||
import { useEffect, useState, useRef } from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import {
|
import { Hyperlink } from '@openedx/paragon';
|
||||||
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
|
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||||
} from '@openedx/paragon';
|
|
||||||
import { Info } from '@openedx/paragon/icons';
|
|
||||||
import { updateModel, useModel } from 'CourseAuthoring/generic/model-store';
|
|
||||||
|
|
||||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
|
||||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||||
import Loading from 'CourseAuthoring/generic/Loading';
|
import { useAppSetting } from 'CourseAuthoring/utils';
|
||||||
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||||
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 messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const ORASettings = ({ onClose }) => {
|
const ORASettings = ({ intl, 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 appId = 'ora_settings';
|
const appId = 'ora_settings';
|
||||||
const appInfo = useModel('courseApps', appId);
|
const appInfo = useModel('courseApps', appId);
|
||||||
|
|
||||||
const [enableFlexiblePeerGrade, saveSetting] = useAppSetting(
|
const [enableFlexiblePeerGrade, saveSetting] = useAppSetting(
|
||||||
'forceOnFlexiblePeerOpenassessments',
|
'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 handleSettingsSave = (values) => saveSetting(values.enableFlexiblePeerGrade);
|
||||||
|
|
||||||
const handleSubmit = async (event) => {
|
const title = (
|
||||||
let success = true;
|
<div>
|
||||||
event.preventDefault();
|
<p>{intl.formatMessage(messages.heading)}</p>
|
||||||
|
<div className="pt-3">
|
||||||
success = success && await handleSettingsSave(formValues);
|
<Hyperlink
|
||||||
await setSaveError(!success);
|
className="text-primary-500 small"
|
||||||
if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) {
|
destination={appInfo.documentationLinks?.learnMoreConfiguration}
|
||||||
success = await dispatch(updateModel({
|
target="_blank"
|
||||||
modelType: 'courseApps',
|
rel="noreferrer noopener"
|
||||||
model: {
|
>
|
||||||
id: appId, enabled: formValues.enableFlexiblePeerGrade,
|
{intl.formatMessage(messages.ORASettingsHelpLink)}
|
||||||
},
|
</Hyperlink>
|
||||||
}));
|
</div>
|
||||||
}
|
</div>
|
||||||
!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 />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalDialog
|
<AppSettingsModal
|
||||||
title={formatMessage(messages.heading)}
|
appId={appId}
|
||||||
isOpen
|
title={title}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size="lg"
|
initialValues={{ enableFlexiblePeerGrade }}
|
||||||
variant={modalVariant}
|
validationSchema={{ enableFlexiblePeerGrade: Yup.boolean() }}
|
||||||
hasCloseButton={isMobile}
|
onSettingsSave={handleSettingsSave}
|
||||||
isFullscreenScroll
|
hideAppToggle
|
||||||
isFullscreenOnMobile
|
|
||||||
>
|
>
|
||||||
<Form onSubmit={handleSubmit} data-testid="proctoringForm">
|
{({ values, handleChange, handleBlur }) => (
|
||||||
<ModalDialog.Header>
|
<FormSwitchGroup
|
||||||
<ModalDialog.Title>
|
id="enable-flexible-peer-grade"
|
||||||
{formatMessage(messages.heading)}
|
name="enableFlexiblePeerGrade"
|
||||||
</ModalDialog.Title>
|
label={intl.formatMessage(messages.enableFlexPeerGradeLabel)}
|
||||||
</ModalDialog.Header>
|
helpText={intl.formatMessage(messages.enableFlexPeerGradeHelp)}
|
||||||
<ModalDialog.Body>
|
onChange={handleChange}
|
||||||
{renderBody()}
|
onBlur={handleBlur}
|
||||||
</ModalDialog.Body>
|
checked={values.enableFlexiblePeerGrade}
|
||||||
<ModalDialog.Footer className="p-4">
|
/>
|
||||||
<ActionRow>
|
)}
|
||||||
<ModalDialog.CloseButton variant="tertiary">
|
</AppSettingsModal>
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ORASettings.propTypes = {
|
ORASettings.propTypes = {
|
||||||
|
intl: intlShape.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ORASettings;
|
export default injectIntl(ORASettings);
|
||||||
|
|||||||
@@ -1,152 +1,33 @@
|
|||||||
import {
|
import { shallow } from '@edx/react-unit-test-utils';
|
||||||
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 ORASettings from './Settings';
|
import ORASettings from './Settings';
|
||||||
import messages from './messages';
|
|
||||||
import {
|
|
||||||
courseId,
|
|
||||||
inititalState,
|
|
||||||
} from './factories/mockData';
|
|
||||||
|
|
||||||
let axiosMock;
|
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||||
let store;
|
...jest.requireActual('@edx/frontend-platform/i18n'), // use actual for all non-hook parts
|
||||||
const oraSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
|
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.
|
const props = {
|
||||||
ReactDOM.createPortal = jest.fn(node => node);
|
onClose: jest.fn().mockName('onClose'),
|
||||||
|
intl: {
|
||||||
const renderComponent = () => (
|
formatMessage: (message) => message.defaultMessage,
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('ORASettings', () => {
|
describe('ORASettings', () => {
|
||||||
beforeEach(async () => {
|
it('should render', () => {
|
||||||
initializeMockApp({
|
const wrapper = shallow(<ORASettings {...props} />);
|
||||||
authenticatedUser: {
|
expect(wrapper.snapshot).toMatchSnapshot();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
`;
|
||||||
@@ -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: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -3,51 +3,19 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
|||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: {
|
heading: {
|
||||||
id: 'course-authoring.pages-resources.ora.heading',
|
id: 'course-authoring.pages-resources.ora.heading',
|
||||||
defaultMessage: 'Configure Flexible Peer Grading',
|
defaultMessage: 'Configure open response assessment',
|
||||||
description: 'Title for the modal dialog header',
|
|
||||||
},
|
},
|
||||||
ORASettingsHelpLink: {
|
ORASettingsHelpLink: {
|
||||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.link',
|
id: 'course-authoring.pages-resources.ora.flex-peer-grading.link',
|
||||||
defaultMessage: 'Learn more about open response assessment settings',
|
defaultMessage: 'Learn more about open response assessment settings',
|
||||||
description: 'Descriptive text for the hyperlink to the docs site',
|
|
||||||
},
|
},
|
||||||
enableFlexPeerGradeLabel: {
|
enableFlexPeerGradeLabel: {
|
||||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.label',
|
id: 'course-authoring.pages-resources.ora.flex-peer-grading.label',
|
||||||
defaultMessage: 'Flex Peer Grading',
|
defaultMessage: 'Flex Peer Grading',
|
||||||
description: 'Label for form switch',
|
|
||||||
},
|
},
|
||||||
enableFlexPeerGradeHelp: {
|
enableFlexPeerGradeHelp: {
|
||||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.help',
|
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.',
|
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.',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,15 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Open Response Assessment configuration for courses using it",
|
"description": "Open Response Assessment configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"prop-types": "*",
|
"prop-types": "*",
|
||||||
"react": "*",
|
"react": "*",
|
||||||
"react-redux": "*",
|
|
||||||
"yup": "*"
|
"yup": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { courseId } = useContext(PagesAndResourcesContext);
|
const { courseId } = useContext(PagesAndResourcesContext);
|
||||||
const courseDetails = useModel('courseDetails', courseId);
|
|
||||||
const org = courseDetails?.org;
|
|
||||||
const appInfo = useModel('courseApps', 'proctoring');
|
const appInfo = useModel('courseApps', 'proctoring');
|
||||||
const alertRef = React.createRef();
|
const alertRef = React.createRef();
|
||||||
const saveStatusAlertRef = React.createRef();
|
const saveStatusAlertRef = React.createRef();
|
||||||
@@ -148,9 +146,9 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
|||||||
setSaveSuccess(true);
|
setSaveSuccess(true);
|
||||||
setSaveError(false);
|
setSaveError(false);
|
||||||
setSubmissionInProgress(false);
|
setSubmissionInProgress(false);
|
||||||
}).catch((error) => {
|
}).catch(() => {
|
||||||
setSaveSuccess(false);
|
setSaveSuccess(false);
|
||||||
setSaveError(error);
|
setSaveError(true);
|
||||||
setSubmissionInProgress(false);
|
setSubmissionInProgress(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -460,44 +458,6 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSaveError() {
|
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 (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@@ -507,7 +467,21 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
|||||||
onClose={() => setSaveError(false)}
|
onClose={() => setSaveError(false)}
|
||||||
dismissible
|
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>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -516,7 +490,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
StudioApiService.getProctoredExamSettingsData(courseId),
|
StudioApiService.getProctoredExamSettingsData(courseId),
|
||||||
ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(),
|
ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(),
|
||||||
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders(org) : Promise.resolve(),
|
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders() : Promise.resolve(),
|
||||||
])
|
])
|
||||||
.then(
|
.then(
|
||||||
([settingsResponse, examConfigResponse, ltiProvidersResponse]) => {
|
([settingsResponse, examConfigResponse, ltiProvidersResponse]) => {
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ import initializeStore from 'CourseAuthoring/store';
|
|||||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||||
import ProctoredExamSettings from './Settings';
|
import ProctoredExamSettings from './Settings';
|
||||||
|
|
||||||
const courseId = 'course-v1%3AedX%2BDemoX%2BDemo_Course';
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
courseId,
|
courseId: 'course-v1%3AedX%2BDemoX%2BDemo_Course',
|
||||||
onClose: () => {},
|
onClose: () => {},
|
||||||
};
|
};
|
||||||
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
|
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
|
||||||
@@ -35,7 +34,7 @@ const intlWrapper = children => (
|
|||||||
let axiosMock;
|
let axiosMock;
|
||||||
|
|
||||||
describe('ProctoredExamSettings', () => {
|
describe('ProctoredExamSettings', () => {
|
||||||
function setupApp(isAdmin = true, org = undefined) {
|
function setupApp(isAdmin = true) {
|
||||||
mergeConfig({
|
mergeConfig({
|
||||||
EXAMS_BASE_URL: 'http://exams.testing.co',
|
EXAMS_BASE_URL: 'http://exams.testing.co',
|
||||||
}, 'CourseAuthoringConfig');
|
}, 'CourseAuthoringConfig');
|
||||||
@@ -53,18 +52,12 @@ describe('ProctoredExamSettings', () => {
|
|||||||
courseApps: {
|
courseApps: {
|
||||||
proctoring: {},
|
proctoring: {},
|
||||||
},
|
},
|
||||||
courseDetails: {
|
|
||||||
[courseId]: {
|
|
||||||
start: Date(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(org ? { courseDetails: { [courseId]: { org } } } : {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
axiosMock.onGet(
|
axiosMock.onGet(
|
||||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers${org ? `?org=${org}` : ''}`,
|
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
|
||||||
).reply(200, [
|
).reply(200, [
|
||||||
{
|
{
|
||||||
name: 'test_lti',
|
name: 'test_lti',
|
||||||
@@ -110,7 +103,9 @@ describe('ProctoredExamSettings', () => {
|
|||||||
screen.getByDisplayValue('mockproc');
|
screen.getByDisplayValue('mockproc');
|
||||||
});
|
});
|
||||||
const selectElement = 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');
|
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsNo');
|
||||||
expect(zendeskTicketInput.checked).toEqual(true);
|
expect(zendeskTicketInput.checked).toEqual(true);
|
||||||
});
|
});
|
||||||
@@ -120,7 +115,9 @@ describe('ProctoredExamSettings', () => {
|
|||||||
screen.getByDisplayValue('mockproc');
|
screen.getByDisplayValue('mockproc');
|
||||||
});
|
});
|
||||||
const selectElement = 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');
|
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
|
||||||
expect(zendeskTicketInput.checked).toEqual(true);
|
expect(zendeskTicketInput.checked).toEqual(true);
|
||||||
});
|
});
|
||||||
@@ -130,7 +127,9 @@ describe('ProctoredExamSettings', () => {
|
|||||||
screen.getByDisplayValue('mockproc');
|
screen.getByDisplayValue('mockproc');
|
||||||
});
|
});
|
||||||
const selectElement = 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');
|
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
|
||||||
expect(zendeskTicketInput.checked).toEqual(true);
|
expect(zendeskTicketInput.checked).toEqual(true);
|
||||||
});
|
});
|
||||||
@@ -177,7 +176,9 @@ describe('ProctoredExamSettings', () => {
|
|||||||
|
|
||||||
let enabledProctoredExamCheck = screen.getAllByLabelText('Proctored exams', { exact: false })[0];
|
let enabledProctoredExamCheck = screen.getAllByLabelText('Proctored exams', { exact: false })[0];
|
||||||
expect(enabledProctoredExamCheck.checked).toEqual(true);
|
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');
|
enabledProctoredExamCheck = screen.getByLabelText('Proctored exams');
|
||||||
expect(enabledProctoredExamCheck.checked).toEqual(false);
|
expect(enabledProctoredExamCheck.checked).toEqual(false);
|
||||||
expect(screen.queryByText('Allow opting out of proctored exams')).toBeNull();
|
expect(screen.queryByText('Allow opting out of proctored exams')).toBeNull();
|
||||||
@@ -192,7 +193,9 @@ describe('ProctoredExamSettings', () => {
|
|||||||
screen.getByDisplayValue('mockproc');
|
screen.getByDisplayValue('mockproc');
|
||||||
});
|
});
|
||||||
const selectElement = 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('allowOptingOutRadio')).toBeNull();
|
||||||
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||||
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
|
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
|
||||||
@@ -234,9 +237,13 @@ describe('ProctoredExamSettings', () => {
|
|||||||
screen.getByDisplayValue('proctortrack');
|
screen.getByDisplayValue('proctortrack');
|
||||||
});
|
});
|
||||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
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');
|
const selectButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(selectButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(selectButton);
|
||||||
|
});
|
||||||
|
|
||||||
// verify alert content and focus management
|
// verify alert content and focus management
|
||||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||||
@@ -245,7 +252,9 @@ describe('ProctoredExamSettings', () => {
|
|||||||
|
|
||||||
// verify alert link links to offending input
|
// verify alert link links to offending input
|
||||||
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
||||||
fireEvent.click(errorLink);
|
await act(async () => {
|
||||||
|
fireEvent.click(errorLink);
|
||||||
|
});
|
||||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||||
});
|
});
|
||||||
@@ -256,12 +265,18 @@ describe('ProctoredExamSettings', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const selectElement = screen.getByDisplayValue('proctortrack');
|
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');
|
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
await act(async () => {
|
||||||
const proctoringForm = screen.getByTestId('proctoringForm');
|
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||||
fireEvent.submit(proctoringForm);
|
});
|
||||||
|
const selectButton = screen.getByTestId('submissionButton');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(selectButton);
|
||||||
|
});
|
||||||
|
|
||||||
// verify alert content and focus management
|
// verify alert content and focus management
|
||||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||||
@@ -271,7 +286,9 @@ describe('ProctoredExamSettings', () => {
|
|||||||
|
|
||||||
// verify alert link links to offending input
|
// verify alert link links to offending input
|
||||||
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
||||||
fireEvent.click(errorLink);
|
await act(async () => {
|
||||||
|
fireEvent.click(errorLink);
|
||||||
|
});
|
||||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||||
});
|
});
|
||||||
@@ -281,11 +298,15 @@ describe('ProctoredExamSettings', () => {
|
|||||||
screen.getByDisplayValue('proctortrack');
|
screen.getByDisplayValue('proctortrack');
|
||||||
});
|
});
|
||||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
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');
|
const enableProctoringElement = screen.getByText('Proctored exams');
|
||||||
fireEvent.click(enableProctoringElement);
|
await act(async () => fireEvent.click(enableProctoringElement));
|
||||||
const selectButton = screen.getByTestId('submissionButton');
|
const selectButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(selectButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(selectButton);
|
||||||
|
});
|
||||||
|
|
||||||
// verify alert content and focus management
|
// verify alert content and focus management
|
||||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||||
@@ -299,22 +320,24 @@ describe('ProctoredExamSettings', () => {
|
|||||||
screen.getByDisplayValue('proctortrack');
|
screen.getByDisplayValue('proctortrack');
|
||||||
});
|
});
|
||||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
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');
|
const enableProctoringElement = screen.getByText('Proctored exams');
|
||||||
fireEvent.click(enableProctoringElement);
|
await act(async () => fireEvent.click(enableProctoringElement));
|
||||||
const selectButton = screen.getByTestId('submissionButton');
|
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
|
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||||
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
||||||
|
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveSuccess');
|
||||||
const errorAlert = screen.getByTestId('saveSuccess');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => {
|
it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => {
|
||||||
@@ -322,20 +345,22 @@ describe('ProctoredExamSettings', () => {
|
|||||||
screen.getByDisplayValue('proctortrack');
|
screen.getByDisplayValue('proctortrack');
|
||||||
});
|
});
|
||||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
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');
|
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
|
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||||
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
||||||
|
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveSuccess');
|
||||||
const errorAlert = screen.getByTestId('saveSuccess');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => {
|
it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => {
|
||||||
@@ -345,7 +370,9 @@ describe('ProctoredExamSettings', () => {
|
|||||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||||
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
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();
|
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -355,9 +382,13 @@ describe('ProctoredExamSettings', () => {
|
|||||||
});
|
});
|
||||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||||
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
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();
|
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();
|
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
|
||||||
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||||
@@ -368,8 +399,12 @@ describe('ProctoredExamSettings', () => {
|
|||||||
screen.getByDisplayValue('proctortrack');
|
screen.getByDisplayValue('proctortrack');
|
||||||
});
|
});
|
||||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
await act(async () => {
|
||||||
fireEvent.submit(selectEscalationEmailElement);
|
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.submit(selectEscalationEmailElement);
|
||||||
|
});
|
||||||
// if the error appears, the form has been submitted
|
// if the error appears, the form has been submitted
|
||||||
expect(screen.getByTestId('escalationEmailError')).toBeDefined();
|
expect(screen.getByTestId('escalationEmailError')).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -423,16 +458,6 @@ describe('ProctoredExamSettings', () => {
|
|||||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
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 () => {
|
it('Enables all proctoring provider options if user administrator and it is after start date', async () => {
|
||||||
const isAdmin = true;
|
const isAdmin = true;
|
||||||
setupApp(isAdmin);
|
setupApp(isAdmin);
|
||||||
@@ -544,9 +569,12 @@ describe('ProctoredExamSettings', () => {
|
|||||||
|
|
||||||
describe('Connection states', () => {
|
describe('Connection states', () => {
|
||||||
it('Shows the spinner before the connection is complete', async () => {
|
it('Shows the spinner before the connection is complete', async () => {
|
||||||
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
|
await act(async () => {
|
||||||
const spinner = await screen.findByRole('status');
|
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
|
||||||
expect(spinner.textContent).toEqual('Loading...');
|
// 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 () => {
|
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} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
let submitButton = screen.getByTestId('submissionButton');
|
let submitButton = screen.getByTestId('submissionButton');
|
||||||
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
|
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
|
||||||
fireEvent.click(submitButton);
|
act(() => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
submitButton = screen.getByTestId('submissionButton');
|
submitButton = screen.getByTestId('submissionButton');
|
||||||
expect(submitButton).toHaveAttribute('disabled');
|
expect(submitButton).toHaveAttribute('disabled');
|
||||||
@@ -610,13 +640,19 @@ describe('ProctoredExamSettings', () => {
|
|||||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
// Make a change to the provider to proctortrack and set the email
|
// Make a change to the provider to proctortrack and set the email
|
||||||
const selectElement = screen.getByDisplayValue('mockproc');
|
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');
|
const escalationEmail = screen.getByTestId('escalationEmail');
|
||||||
expect(escalationEmail.value).toEqual('test@example.com');
|
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');
|
expect(escalationEmail.value).toEqual('proctortrack@example.com');
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
expect(axiosMock.history.post.length).toBe(1);
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
||||||
proctored_exam_settings: {
|
proctored_exam_settings: {
|
||||||
@@ -628,13 +664,11 @@ describe('ProctoredExamSettings', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveSuccess');
|
||||||
const errorAlert = screen.getByTestId('saveSuccess');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
|
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
|
||||||
@@ -644,7 +678,9 @@ describe('ProctoredExamSettings', () => {
|
|||||||
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
|
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
|
||||||
|
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
expect(axiosMock.history.post.length).toBe(1);
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
||||||
proctored_exam_settings: {
|
proctored_exam_settings: {
|
||||||
@@ -655,28 +691,32 @@ describe('ProctoredExamSettings', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveSuccess');
|
||||||
const errorAlert = screen.getByTestId('saveSuccess');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
|
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
|
||||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
// Make a change to the provider to test_lti and set the email
|
// Make a change to the provider to test_lti and set the email
|
||||||
const selectElement = 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' } });
|
||||||
|
});
|
||||||
|
|
||||||
const escalationEmail = screen.getByTestId('escalationEmail');
|
const escalationEmail = screen.getByTestId('escalationEmail');
|
||||||
expect(escalationEmail.value).toEqual('test@example.com');
|
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');
|
expect(escalationEmail.value).toEqual('test_lti@example.com');
|
||||||
|
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
// update exam service config
|
// update exam service config
|
||||||
expect(axiosMock.history.patch.length).toBe(1);
|
expect(axiosMock.history.patch.length).toBe(1);
|
||||||
@@ -696,19 +736,19 @@ describe('ProctoredExamSettings', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveSuccess');
|
||||||
const errorAlert = screen.getByTestId('saveSuccess');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
|
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
|
||||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
// update exam service config
|
// update exam service config
|
||||||
expect(axiosMock.history.patch.length).toBe(1);
|
expect(axiosMock.history.patch.length).toBe(1);
|
||||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
||||||
@@ -726,13 +766,11 @@ describe('ProctoredExamSettings', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveSuccess');
|
||||||
const errorAlert = screen.getByTestId('saveSuccess');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Does not update exam service if lti is not enabled in studio', async () => {
|
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} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
// does not update exam service config
|
// does not update exam service config
|
||||||
expect(axiosMock.history.patch.length).toBe(0);
|
expect(axiosMock.history.patch.length).toBe(0);
|
||||||
// does update studio
|
// does update studio
|
||||||
@@ -766,13 +806,11 @@ describe('ProctoredExamSettings', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveSuccess');
|
||||||
const errorAlert = screen.getByTestId('saveSuccess');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Makes studio API call generated error', async () => {
|
it('Makes studio API call generated error', async () => {
|
||||||
@@ -782,15 +820,15 @@ describe('ProctoredExamSettings', () => {
|
|||||||
|
|
||||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
expect(axiosMock.history.post.length).toBe(1);
|
fireEvent.click(submitButton);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
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 () => {
|
it('Makes exams API call generated error', async () => {
|
||||||
@@ -800,33 +838,15 @@ describe('ProctoredExamSettings', () => {
|
|||||||
|
|
||||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
expect(axiosMock.history.post.length).toBe(1);
|
fireEvent.click(submitButton);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
await waitFor(() => {
|
const errorAlert = screen.getByTestId('saveError');
|
||||||
const errorAlert = screen.getByTestId('saveError');
|
expect(errorAlert.textContent).toEqual(
|
||||||
expect(errorAlert.textContent).toEqual(
|
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||||
expect.stringContaining('You do not have permission to edit proctored exam settings for this course'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(errorAlert);
|
||||||
expect(document.activeElement).toEqual(errorAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Manages focus correctly after different save statuses', async () => {
|
it('Manages focus correctly after different save statuses', async () => {
|
||||||
@@ -837,30 +857,30 @@ describe('ProctoredExamSettings', () => {
|
|||||||
|
|
||||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
const submitButton = screen.getByTestId('submissionButton');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
expect(axiosMock.history.post.length).toBe(1);
|
fireEvent.click(submitButton);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
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
|
// now make a call that will allow for a successful save
|
||||||
axiosMock.onPost(
|
axiosMock.onPost(
|
||||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||||
).reply(200, 'success');
|
).reply(200, 'success');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
expect(axiosMock.history.post.length).toBe(2);
|
expect(axiosMock.history.post.length).toBe(2);
|
||||||
await waitFor(() => {
|
const successAlert = screen.getByTestId('saveSuccess');
|
||||||
const successAlert = screen.getByTestId('saveSuccess');
|
expect(successAlert.textContent).toEqual(
|
||||||
expect(successAlert.textContent).toEqual(
|
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
);
|
||||||
);
|
expect(document.activeElement).toEqual(successAlert);
|
||||||
expect(document.activeElement).toEqual(successAlert);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Include Zendesk ticket in post request if user is not an admin', async () => {
|
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} />)));
|
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||||
// Make a change to the proctoring provider
|
// Make a change to the proctoring provider
|
||||||
const selectElement = screen.getByDisplayValue('mockproc');
|
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');
|
const submitButton = screen.getByTestId('submissionButton');
|
||||||
fireEvent.click(submitButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
expect(axiosMock.history.post.length).toBe(1);
|
expect(axiosMock.history.post.length).toBe(1);
|
||||||
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
||||||
proctored_exam_settings: {
|
proctored_exam_settings: {
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
const messages = defineMessages({
|
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': {
|
'authoring.proctoring.no': {
|
||||||
id: 'authoring.proctoring.no',
|
id: 'authoring.proctoring.no',
|
||||||
defaultMessage: 'No',
|
defaultMessage: 'No',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Proctoring configuration for courses using it",
|
"description": "Proctoring configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"classnames": "*",
|
"classnames": "*",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"moment": "*"
|
"moment": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Progress configuration for courses using it",
|
"description": "Progress configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"prop-types": "*",
|
"prop-types": "*",
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"yup": "*"
|
"yup": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ const TeamSettings = ({
|
|||||||
description: '',
|
description: '',
|
||||||
type: GroupTypes.OPEN,
|
type: GroupTypes.OPEN,
|
||||||
maxTeamSize: null,
|
maxTeamSize: null,
|
||||||
userPartitionId: null,
|
|
||||||
id: null,
|
id: null,
|
||||||
key: uuid(),
|
key: uuid(),
|
||||||
};
|
};
|
||||||
@@ -39,7 +38,6 @@ const TeamSettings = ({
|
|||||||
type: group.type,
|
type: group.type,
|
||||||
description: group.description,
|
description: group.description,
|
||||||
max_team_size: group.maxTeamSize,
|
max_team_size: group.maxTeamSize,
|
||||||
user_partition_id: group.userPartitionId,
|
|
||||||
}));
|
}));
|
||||||
return saveSettings({
|
return saveSettings({
|
||||||
team_sets: groups,
|
team_sets: groups,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Teams configuration for courses using it",
|
"description": "Teams configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"formik": "*",
|
"formik": "*",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"yup": "*"
|
"yup": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
enablePublicWikiHelp: {
|
enablePublicWikiHelp: {
|
||||||
id: 'course-authoring.pages-resources.wiki.enable-public-wiki.help',
|
id: 'course-authoring.pages-resources.wiki.enable-public-wiki.help',
|
||||||
defaultMessage: `If enabled, any registered user can view the course wiki
|
defaultMessage: `If enabled, edX users can view the course wiki even when
|
||||||
even if they are not enrolled in the course`,
|
they're not enrolled in the course.`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Wiki configuration for courses using it",
|
"description": "Wiki configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"prop-types": "*",
|
"prop-types": "*",
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"yup": "*"
|
"yup": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||||
import {
|
import {
|
||||||
findByTestId, queryByTestId, render, waitFor, getByText, fireEvent,
|
queryByTestId, render, waitFor, getByText, fireEvent,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||||
@@ -106,9 +106,8 @@ describe('XpertUnitSummarySettings', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Shows switch on if enabled from backend', async () => {
|
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(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 () => {
|
test('Shows switch on if disabled from backend', async () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Xpert Unit Summaries configuration for courses using it",
|
"description": "Xpert Unit Summaries configuration for courses using it",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edx/frontend-app-authoring": "*",
|
"@edx/frontend-app-course-authoring": "*",
|
||||||
"@edx/frontend-platform": "*",
|
"@edx/frontend-platform": "*",
|
||||||
"@openedx/paragon": "*",
|
"@openedx/paragon": "*",
|
||||||
"formik": "*",
|
"formik": "*",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"react-router-dom": "*"
|
"react-router-dom": "*"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edx/frontend-app-authoring": {
|
"@edx/frontend-app-course-authoring": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,12 +137,12 @@ const ResetUnitsButton = ({
|
|||||||
|
|
||||||
const getResetButtonState = () => {
|
const getResetButtonState = () => {
|
||||||
switch (resetStatusRequestStatus) {
|
switch (resetStatusRequestStatus) {
|
||||||
case RequestStatus.PENDING:
|
case RequestStatus.PENDING:
|
||||||
return 'pending';
|
return 'pending';
|
||||||
case RequestStatus.SUCCESSFUL:
|
case RequestStatus.SUCCESSFUL:
|
||||||
return 'finish';
|
return 'finish';
|
||||||
default:
|
default:
|
||||||
return 'default';
|
return 'default';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ const SettingsModal = ({
|
|||||||
success = success && await onSettingsSave(values);
|
success = success && await onSettingsSave(values);
|
||||||
}
|
}
|
||||||
setSaveError(!success);
|
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) => {
|
const handleFormikSubmit = ({ handleSubmit, errors }) => async (event) => {
|
||||||
|
|||||||
@@ -19,6 +19,15 @@
|
|||||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||||
"matchUpdateTypes": ["minor", "patch"],
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
"automerge": false
|
"automerge": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackagePatterns": ["@edx/frontend-lib-content-components"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"automerge": false,
|
||||||
|
"schedule": [
|
||||||
|
"after 1am",
|
||||||
|
"before 11pm"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,29 +5,46 @@ import { useDispatch, useSelector } from 'react-redux';
|
|||||||
import {
|
import {
|
||||||
useLocation,
|
useLocation,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
|
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||||
import Header from './header';
|
import Header from './header';
|
||||||
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
|
import { fetchCourseDetail } from './data/thunks';
|
||||||
import { useModel } from './generic/model-store';
|
import { useModel } from './generic/model-store';
|
||||||
import NotFoundAlert from './generic/NotFoundAlert';
|
import NotFoundAlert from './generic/NotFoundAlert';
|
||||||
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
||||||
import { fetchOnlyStudioHomeData } from './studio-home/data/thunks';
|
|
||||||
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
|
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
|
||||||
import { RequestStatus } from './data/constants';
|
import { RequestStatus } from './data/constants';
|
||||||
import Loading from './generic/Loading';
|
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 CourseAuthoringPage = ({ courseId, children }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchCourseDetail(courseId));
|
dispatch(fetchCourseDetail(courseId));
|
||||||
dispatch(fetchWaffleFlags(courseId));
|
|
||||||
}, [courseId]);
|
}, [courseId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchOnlyStudioHomeData());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const courseDetail = useModel('courseDetails', courseId);
|
const courseDetail = useModel('courseDetails', courseId);
|
||||||
|
|
||||||
const courseNumber = courseDetail ? courseDetail.number : null;
|
const courseNumber = courseDetail ? courseDetail.number : null;
|
||||||
@@ -50,23 +67,23 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
|
||||||
{/* While V2 Editors are temporarily served from their own pages
|
{/* While V2 Editors are temporarily served from their own pages
|
||||||
using url pattern containing /editor/,
|
using url pattern containing /editor/,
|
||||||
we shouldn't have the header and footer on these pages.
|
we shouldn't have the header and footer on these pages.
|
||||||
This functionality will be removed in TNL-9591 */}
|
This functionality will be removed in TNL-9591 */}
|
||||||
{inProgress ? !isEditor && <Loading />
|
{inProgress ? !isEditor && <Loading />
|
||||||
: (!isEditor && (
|
: (!isEditor && (
|
||||||
<Header
|
<AppHeader
|
||||||
number={courseNumber}
|
courseNumber={courseNumber}
|
||||||
org={courseOrg}
|
courseOrg={courseOrg}
|
||||||
title={courseTitle}
|
courseTitle={courseTitle}
|
||||||
contextId={courseId}
|
courseId={courseId}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
{!inProgress && !isEditor && <StudioFooterSlot />}
|
{!inProgress && !isEditor && <StudioFooter />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 CourseAuthoringPage from './CourseAuthoringPage';
|
||||||
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
||||||
import { executeThunk } from './utils';
|
import { executeThunk } from './utils';
|
||||||
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
||||||
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
|
import { fetchCourseDetail } from './data/thunks';
|
||||||
import { getApiWaffleFlagsUrl } from './data/api';
|
|
||||||
import { initializeMocks, render } from './testUtils';
|
|
||||||
|
|
||||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||||
let mockPathname = '/evilguy/';
|
let mockPathname = '/evilguy/';
|
||||||
@@ -19,14 +25,17 @@ jest.mock('react-router-dom', () => ({
|
|||||||
let axiosMock;
|
let axiosMock;
|
||||||
let store;
|
let store;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
const mocks = initializeMocks();
|
initializeMockApp({
|
||||||
store = mocks.reduxStore;
|
authenticatedUser: {
|
||||||
axiosMock = mocks.axiosMock;
|
userId: 3,
|
||||||
axiosMock
|
username: 'abc123',
|
||||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
administrator: true,
|
||||||
.reply(200, {});
|
roles: [],
|
||||||
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
|
},
|
||||||
|
});
|
||||||
|
store = initializeStore();
|
||||||
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Editor Pages Load no header', () => {
|
describe('Editor Pages Load no header', () => {
|
||||||
@@ -42,9 +51,13 @@ describe('Editor Pages Load no header', () => {
|
|||||||
mockPathname = '/editor/';
|
mockPathname = '/editor/';
|
||||||
await mockStoreSuccess();
|
await mockStoreSuccess();
|
||||||
const wrapper = render(
|
const wrapper = render(
|
||||||
<CourseAuthoringPage courseId={courseId}>
|
<AppProvider store={store}>
|
||||||
<PagesAndResources courseId={courseId} />
|
<IntlProvider locale="en">
|
||||||
</CourseAuthoringPage>
|
<CourseAuthoringPage courseId={courseId}>
|
||||||
|
<PagesAndResources courseId={courseId} />
|
||||||
|
</CourseAuthoringPage>
|
||||||
|
</IntlProvider>
|
||||||
|
</AppProvider>
|
||||||
,
|
,
|
||||||
);
|
);
|
||||||
expect(wrapper.queryByRole('status')).not.toBeInTheDocument();
|
expect(wrapper.queryByRole('status')).not.toBeInTheDocument();
|
||||||
@@ -53,9 +66,13 @@ describe('Editor Pages Load no header', () => {
|
|||||||
mockPathname = '/evilguy/';
|
mockPathname = '/evilguy/';
|
||||||
await mockStoreSuccess();
|
await mockStoreSuccess();
|
||||||
const wrapper = render(
|
const wrapper = render(
|
||||||
<CourseAuthoringPage courseId={courseId}>
|
<AppProvider store={store}>
|
||||||
<PagesAndResources courseId={courseId} />
|
<IntlProvider locale="en">
|
||||||
</CourseAuthoringPage>
|
<CourseAuthoringPage courseId={courseId}>
|
||||||
|
<PagesAndResources courseId={courseId} />
|
||||||
|
</CourseAuthoringPage>
|
||||||
|
</IntlProvider>
|
||||||
|
</AppProvider>
|
||||||
,
|
,
|
||||||
);
|
);
|
||||||
expect(wrapper.queryByRole('status')).toBeInTheDocument();
|
expect(wrapper.queryByRole('status')).toBeInTheDocument();
|
||||||
@@ -83,7 +100,14 @@ describe('Course authoring page', () => {
|
|||||||
};
|
};
|
||||||
test('renders not found page on non-existent course key', async () => {
|
test('renders not found page on non-existent course key', async () => {
|
||||||
await mockStoreNotFound();
|
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();
|
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
test('does not render not found page on other kinds of error', async () => {
|
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.
|
// found alert is not present.
|
||||||
const contentTestId = 'courseAuthoringPageContent';
|
const contentTestId = 'courseAuthoringPageContent';
|
||||||
const wrapper = render(
|
const wrapper = render(
|
||||||
<CourseAuthoringPage courseId={courseId}>
|
<AppProvider store={store}>
|
||||||
<div data-testid={contentTestId} />
|
<IntlProvider locale="en">
|
||||||
</CourseAuthoringPage>
|
<CourseAuthoringPage courseId={courseId}>
|
||||||
|
<div data-testid={contentTestId} />
|
||||||
|
</CourseAuthoringPage>
|
||||||
|
</IntlProvider>
|
||||||
|
</AppProvider>
|
||||||
,
|
,
|
||||||
);
|
);
|
||||||
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
|
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { PageWrap } from '@edx/frontend-platform/react';
|
import { PageWrap } from '@edx/frontend-platform/react';
|
||||||
import { Textbooks } from 'CourseAuthoring/textbooks';
|
|
||||||
import CourseAuthoringPage from './CourseAuthoringPage';
|
import CourseAuthoringPage from './CourseAuthoringPage';
|
||||||
import { PagesAndResources } from './pages-and-resources';
|
import { PagesAndResources } from './pages-and-resources';
|
||||||
import EditorContainer from './editors/EditorContainer';
|
import EditorContainer from './editors/EditorContainer';
|
||||||
@@ -18,15 +17,10 @@ import { GradingSettings } from './grading-settings';
|
|||||||
import CourseTeam from './course-team/CourseTeam';
|
import CourseTeam from './course-team/CourseTeam';
|
||||||
import { CourseUpdates } from './course-updates';
|
import { CourseUpdates } from './course-updates';
|
||||||
import { CourseUnit } from './course-unit';
|
import { CourseUnit } from './course-unit';
|
||||||
import { Certificates } from './certificates';
|
|
||||||
import CourseExportPage from './export-page/CourseExportPage';
|
import CourseExportPage from './export-page/CourseExportPage';
|
||||||
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
|
|
||||||
import CourseImportPage from './import-page/CourseImportPage';
|
import CourseImportPage from './import-page/CourseImportPage';
|
||||||
import { DECODED_ROUTES } from './constants';
|
import { DECODED_ROUTES } from './constants';
|
||||||
import CourseChecklist from './course-checklist';
|
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:
|
* As of this writing, these routes are mounted at a path prefixed with the following:
|
||||||
@@ -58,10 +52,6 @@ const CourseAuthoringRoutes = () => {
|
|||||||
path="course_info"
|
path="course_info"
|
||||||
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
|
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="libraries"
|
|
||||||
element={<PageWrap><CourseLibraries courseId={courseId} /></PageWrap>}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="assets"
|
path="assets"
|
||||||
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
|
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
|
||||||
@@ -86,16 +76,16 @@ const CourseAuthoringRoutes = () => {
|
|||||||
<Route
|
<Route
|
||||||
key={path}
|
key={path}
|
||||||
path={path}
|
path={path}
|
||||||
element={<PageWrap><IframeProvider><CourseUnit courseId={courseId} /></IframeProvider></PageWrap>}
|
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Route
|
<Route
|
||||||
path="editor/course-videos/:blockId"
|
path="editor/course-videos/:blockId"
|
||||||
element={<PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap>}
|
element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? <PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap> : null}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="editor/:blockType/:blockId?"
|
path="editor/:blockType/:blockId?"
|
||||||
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
|
element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? <PageWrap><EditorContainer courseId={courseId} /></PageWrap> : null}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="settings/details"
|
path="settings/details"
|
||||||
@@ -109,10 +99,6 @@ const CourseAuthoringRoutes = () => {
|
|||||||
path="course_team"
|
path="course_team"
|
||||||
element={<PageWrap><CourseTeam courseId={courseId} /></PageWrap>}
|
element={<PageWrap><CourseTeam courseId={courseId} /></PageWrap>}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="group_configurations"
|
|
||||||
element={<PageWrap><GroupConfigurations courseId={courseId} /></PageWrap>}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="settings/advanced"
|
path="settings/advanced"
|
||||||
element={<PageWrap><AdvancedSettings courseId={courseId} /></PageWrap>}
|
element={<PageWrap><AdvancedSettings courseId={courseId} /></PageWrap>}
|
||||||
@@ -125,22 +111,10 @@ const CourseAuthoringRoutes = () => {
|
|||||||
path="export"
|
path="export"
|
||||||
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
|
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="optimizer"
|
|
||||||
element={<PageWrap><CourseOptimizerPage courseId={courseId} /></PageWrap>}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="checklists"
|
path="checklists"
|
||||||
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
|
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="certificates"
|
|
||||||
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates courseId={courseId} /></PageWrap> : null}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="textbooks"
|
|
||||||
element={<PageWrap><Textbooks courseId={courseId} /></PageWrap>}
|
|
||||||
/>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</CourseAuthoringPage>
|
</CourseAuthoringPage>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 CourseAuthoringRoutes from './CourseAuthoringRoutes';
|
||||||
import { executeThunk } from './utils';
|
import initializeStore from './store';
|
||||||
import { getApiWaffleFlagsUrl } from './data/api';
|
|
||||||
import { fetchWaffleFlags } from './data/thunks';
|
|
||||||
import {
|
|
||||||
screen, initializeMocks, render, waitFor,
|
|
||||||
} from './testUtils';
|
|
||||||
|
|
||||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||||
const pagesAndResourcesMockText = 'Pages And Resources';
|
const pagesAndResourcesMockText = 'Pages And Resources';
|
||||||
@@ -21,10 +21,9 @@ jest.mock('react-router-dom', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the TinyMceWidget
|
// Mock the TinyMceWidget from frontend-lib-content-components
|
||||||
jest.mock('./editors/sharedComponents/TinyMceWidget', () => ({
|
jest.mock('@edx/frontend-lib-content-components', () => ({
|
||||||
__esModule: true, // Required to mock a default export
|
TinyMceWidget: () => <div>Widget</div>,
|
||||||
default: () => <div>Widget</div>,
|
|
||||||
Footer: () => <div>Footer</div>,
|
Footer: () => <div>Footer</div>,
|
||||||
prepareEditorRef: jest.fn(() => ({
|
prepareEditorRef: jest.fn(() => ({
|
||||||
refReady: true,
|
refReady: true,
|
||||||
@@ -50,59 +49,68 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('<CourseAuthoringRoutes>', () => {
|
describe('<CourseAuthoringRoutes>', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
const { axiosMock, reduxStore } = initializeMocks();
|
initializeMockApp({
|
||||||
store = reduxStore;
|
authenticatedUser: {
|
||||||
axiosMock
|
userId: 3,
|
||||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
username: 'abc123',
|
||||||
.reply(200, {});
|
administrator: true,
|
||||||
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
|
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(
|
render(
|
||||||
<CourseAuthoringRoutes />,
|
<AppProvider store={store} wrapWithRouter={false}>
|
||||||
{ routerProps: { initialEntries: ['/pages-and-resources'] } },
|
<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(
|
render(
|
||||||
<CourseAuthoringRoutes />,
|
<AppProvider store={store} wrapWithRouter={false}>
|
||||||
{ routerProps: { initialEntries: ['/editor/video/block-id'] } },
|
<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(
|
render(
|
||||||
<CourseAuthoringRoutes />,
|
<AppProvider store={store} wrapWithRouter={false}>
|
||||||
{ routerProps: { initialEntries: ['/editor/course-videos/block-id'] } },
|
<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,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export default {
|
|
||||||
content: {
|
|
||||||
id: 67,
|
|
||||||
userId: 3,
|
|
||||||
created: '2024-01-16T13:09:11.540615Z',
|
|
||||||
purpose: 'clipboard',
|
|
||||||
status: 'ready',
|
|
||||||
blockType: 'vertical',
|
|
||||||
blockTypeDisplay: 'Unit',
|
|
||||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
|
|
||||||
displayName: 'Introduction: Video and Sequences',
|
|
||||||
},
|
|
||||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
|
||||||
sourceContextTitle: 'Demonstration Course',
|
|
||||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export default {
|
|
||||||
content: {
|
|
||||||
id: 69,
|
|
||||||
userId: 3,
|
|
||||||
created: '2024-01-16T13:33:21.314439Z',
|
|
||||||
purpose: 'clipboard',
|
|
||||||
status: 'ready',
|
|
||||||
blockType: 'html',
|
|
||||||
blockTypeDisplay: 'Text',
|
|
||||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/69/olx',
|
|
||||||
displayName: 'Blank HTML Page',
|
|
||||||
},
|
|
||||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@html+block@html1',
|
|
||||||
sourceContextTitle: 'Demonstration Course',
|
|
||||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { default as clipboardUnit } from './clipboardUnit';
|
|
||||||
export { default as clipboardSubsection } from './clipboardSubsection';
|
|
||||||
export { default as clipboardXBlock } from './clipboardXBlock';
|
|
||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { Container } from '@openedx/paragon';
|
import { Container } from '@openedx/paragon';
|
||||||
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
|
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||||
|
|
||||||
import Header from '../header';
|
import Header from '../header';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
@@ -29,7 +29,7 @@ const AccessibilityPage = ({
|
|||||||
<AccessibilityBody {...{ email, communityAccessibilityLink }} />
|
<AccessibilityBody {...{ email, communityAccessibilityLink }} />
|
||||||
<AccessibilityForm accessibilityEmail={email} />
|
<AccessibilityForm accessibilityEmail={email} />
|
||||||
</Container>
|
</Container>
|
||||||
<StudioFooterSlot />
|
<StudioFooter />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
|||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
pageTitle: {
|
pageTitle: {
|
||||||
id: 'course-authoring.accessibility.page.title',
|
id: 'course-authoring.import.page.title',
|
||||||
defaultMessage: 'Studio Accessibility Policy| {siteName}',
|
defaultMessage: 'Studio Accessibility Policy| {siteName}',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
|
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
|
||||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
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 AlertProctoringError from '../generic/AlertProctoringError';
|
||||||
import { useModel } from '../generic/model-store';
|
import { useModel } from '../generic/model-store';
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
export { default as advancedSettingsMock } from './advancedSettings';
|
export { default as advancedSettingsMock } from './advancedSettings';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
import { convertObjectToSnakeCase } from '../../utils';
|
import { convertObjectToSnakeCase } from '../../utils';
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
export { default as AdvancedSettings } from './AdvancedSettings';
|
export { default as AdvancedSettings } from './AdvancedSettings';
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const SettingCard = ({
|
|||||||
iconAs={Icon}
|
iconAs={Icon}
|
||||||
alt={intl.formatMessage(messages.helpButtonText)}
|
alt={intl.formatMessage(messages.helpButtonText)}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="flex-shrink-0 ml-1 mr-2"
|
className=" ml-1 mr-2"
|
||||||
/>
|
/>
|
||||||
<ModalPopup
|
<ModalPopup
|
||||||
hasArrow
|
hasArrow
|
||||||
|
|||||||
@@ -79,7 +79,3 @@
|
|||||||
color: $black;
|
color: $black;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-datepicker-popper {
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,3 @@
|
|||||||
.mw-300px {
|
.mw-300px {
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-0 {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Helmet } from 'react-helmet';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import Placeholder from '../editors/Placeholder';
|
|
||||||
import { RequestStatus } from '../data/constants';
|
|
||||||
import Loading from '../generic/Loading';
|
|
||||||
import useCertificates from './hooks/useCertificates';
|
|
||||||
import CertificateWithoutModes from './certificate-without-modes/CertificateWithoutModes';
|
|
||||||
import EmptyCertificatesWithModes from './empty-certificates-with-modes/EmptyCertificatesWithModes';
|
|
||||||
import CertificatesList from './certificates-list/CertificatesList';
|
|
||||||
import CertificateCreateForm from './certificate-create-form/CertificateCreateForm';
|
|
||||||
import CertificateEditForm from './certificate-edit-form/CertificateEditForm';
|
|
||||||
import { MODE_STATES } from './data/constants';
|
|
||||||
import MainLayout from './layout/MainLayout';
|
|
||||||
|
|
||||||
const MODE_COMPONENTS = {
|
|
||||||
[MODE_STATES.noModes]: CertificateWithoutModes,
|
|
||||||
[MODE_STATES.noCertificates]: EmptyCertificatesWithModes,
|
|
||||||
[MODE_STATES.create]: CertificateCreateForm,
|
|
||||||
[MODE_STATES.view]: CertificatesList,
|
|
||||||
[MODE_STATES.editAll]: CertificateEditForm,
|
|
||||||
};
|
|
||||||
|
|
||||||
const Certificates = ({ courseId }) => {
|
|
||||||
const {
|
|
||||||
certificates, componentMode, isLoading, loadingStatus, pageHeadTitle, hasCertificateModes,
|
|
||||||
} = useCertificates({ courseId });
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Loading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loadingStatus === RequestStatus.DENIED) {
|
|
||||||
return (
|
|
||||||
<div className="row justify-content-center m-6" data-testid="request-denied-placeholder">
|
|
||||||
<Placeholder />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModeComponent = MODE_COMPONENTS[componentMode] || MODE_COMPONENTS[MODE_STATES.noModes];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Helmet><title>{pageHeadTitle}</title></Helmet>
|
|
||||||
<MainLayout courseId={courseId} showHeaderButtons={hasCertificateModes && certificates?.length > 0}>
|
|
||||||
<ModeComponent courseId={courseId} />
|
|
||||||
</MainLayout>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Certificates.propTypes = {
|
|
||||||
courseId: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Certificates;
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
import { render, waitFor } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
|
|
||||||
import { RequestStatus } from '../data/constants';
|
|
||||||
import { executeThunk } from '../utils';
|
|
||||||
import initializeStore from '../store';
|
|
||||||
import { getCertificatesApiUrl } from './data/api';
|
|
||||||
import { fetchCertificates } from './data/thunks';
|
|
||||||
import { certificatesDataMock } from './__mocks__';
|
|
||||||
import Certificates from './Certificates';
|
|
||||||
import messages from './messages';
|
|
||||||
|
|
||||||
let axiosMock;
|
|
||||||
let store;
|
|
||||||
const courseId = 'course-123';
|
|
||||||
|
|
||||||
const renderComponent = (props) => render(
|
|
||||||
<AppProvider store={store} messages={{}}>
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<Certificates courseId={courseId} {...props} />
|
|
||||||
</IntlProvider>
|
|
||||||
</AppProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('Certificates', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore();
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders WithoutModes when there are certificates but no certificate modes', async () => {
|
|
||||||
const noModesMock = {
|
|
||||||
...certificatesDataMock,
|
|
||||||
courseModes: [],
|
|
||||||
hasCertificateModes: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCertificatesApiUrl(courseId))
|
|
||||||
.reply(200, noModesMock);
|
|
||||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
|
||||||
|
|
||||||
const { getByText, queryByRole } = renderComponent();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(getByText(messages.withoutModesText.defaultMessage)).toBeInTheDocument();
|
|
||||||
expect(queryByRole('button', { name: messages.headingActionsPreview.defaultMessage })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders WithoutModes when there are no certificate modes', async () => {
|
|
||||||
const noModesMock = {
|
|
||||||
...certificatesDataMock,
|
|
||||||
certificates: [],
|
|
||||||
courseModes: [],
|
|
||||||
hasCertificateModes: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCertificatesApiUrl(courseId))
|
|
||||||
.reply(200, noModesMock);
|
|
||||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
|
||||||
|
|
||||||
const { getByText, queryByText } = renderComponent();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(getByText(messages.withoutModesText.defaultMessage)).toBeInTheDocument();
|
|
||||||
expect(queryByText(messages.noCertificatesText.defaultMessage)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders WithModesWithoutCertificates when there are modes but no certificates', async () => {
|
|
||||||
const noCertificatesMock = {
|
|
||||||
...certificatesDataMock,
|
|
||||||
certificates: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCertificatesApiUrl(courseId))
|
|
||||||
.reply(200, noCertificatesMock);
|
|
||||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
|
||||||
|
|
||||||
const { getByText, queryByText } = renderComponent();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(getByText(messages.noCertificatesText.defaultMessage)).toBeInTheDocument();
|
|
||||||
expect(queryByText(messages.withoutModesText.defaultMessage)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders CertificatesList when there are modes and certificates', async () => {
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCertificatesApiUrl(courseId))
|
|
||||||
.reply(200, certificatesDataMock);
|
|
||||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
|
||||||
|
|
||||||
const { getByText, queryByText, getByTestId } = renderComponent();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(getByTestId('certificates-list')).toBeInTheDocument();
|
|
||||||
expect(getByText(certificatesDataMock.courseTitle)).toBeInTheDocument();
|
|
||||||
expect(getByText(certificatesDataMock.certificates[0].signatories[0].name)).toBeInTheDocument();
|
|
||||||
expect(queryByText(messages.noCertificatesText.defaultMessage)).not.toBeInTheDocument();
|
|
||||||
expect(queryByText(messages.withoutModesText.defaultMessage)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders CertificateCreateForm when there is componentMode = MODE_STATES.create', async () => {
|
|
||||||
const noCertificatesMock = {
|
|
||||||
...certificatesDataMock,
|
|
||||||
certificates: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCertificatesApiUrl(courseId))
|
|
||||||
.reply(200, noCertificatesMock);
|
|
||||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
|
||||||
|
|
||||||
const { queryByTestId, getByTestId, getByRole } = renderComponent();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const addCertificateButton = getByRole('button', { name: messages.setupCertificateBtn.defaultMessage });
|
|
||||||
userEvent.click(addCertificateButton);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getByTestId('certificates-create-form')).toBeInTheDocument();
|
|
||||||
expect(getByTestId('certificate-details-form')).toBeInTheDocument();
|
|
||||||
expect(getByTestId('signatory-form')).toBeInTheDocument();
|
|
||||||
expect(queryByTestId('certificate-details')).not.toBeInTheDocument();
|
|
||||||
expect(queryByTestId('signatory')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders CertificateEditForm when there is componentMode = MODE_STATES.editAll', async () => {
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCertificatesApiUrl(courseId))
|
|
||||||
.reply(200, certificatesDataMock);
|
|
||||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
|
||||||
|
|
||||||
const { queryByTestId, getByTestId, getAllByLabelText } = renderComponent();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const editCertificateButton = getAllByLabelText(messages.editTooltip.defaultMessage)[0];
|
|
||||||
userEvent.click(editCertificateButton);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getByTestId('certificates-edit-form')).toBeInTheDocument();
|
|
||||||
expect(getByTestId('certificate-details-form')).toBeInTheDocument();
|
|
||||||
expect(getByTestId('signatory-form')).toBeInTheDocument();
|
|
||||||
expect(queryByTestId('certificate-details')).not.toBeInTheDocument();
|
|
||||||
expect(queryByTestId('signatory')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders placeholder if request fails', async () => {
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCertificatesApiUrl(courseId))
|
|
||||||
.reply(403, certificatesDataMock);
|
|
||||||
|
|
||||||
const { getByTestId } = renderComponent();
|
|
||||||
|
|
||||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
|
||||||
|
|
||||||
expect(getByTestId('request-denied-placeholder')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates loading status if request fails', async () => {
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCertificatesApiUrl(courseId))
|
|
||||||
.reply(404, certificatesDataMock);
|
|
||||||
|
|
||||||
renderComponent();
|
|
||||||
|
|
||||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
|
||||||
|
|
||||||
expect(store.getState().certificates.loadingStatus).toBe(RequestStatus.FAILED);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
module.exports = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
courseTitle: 'Course Title 1',
|
|
||||||
signatories: [
|
|
||||||
{
|
|
||||||
name: 'Signatory Name 1',
|
|
||||||
title: 'Signatory Title 1',
|
|
||||||
organization: 'Signatory Organization 1',
|
|
||||||
signatureImagePath: '/path/to/signature1/image.png',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Signatory Name 2',
|
|
||||||
title: 'Signatory Title 2',
|
|
||||||
organization: 'Signatory Organization 2',
|
|
||||||
signatureImagePath: '/path/to/signature2/image.png',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
certificateActivationHandlerUrl: '/certificates/activation/course-v1:org+101+101/',
|
|
||||||
certificateWebViewUrl: '//certificates/course/course-v1:org+101+101?preview=honor',
|
|
||||||
certificates: [
|
|
||||||
{
|
|
||||||
courseTitle: 'Course title',
|
|
||||||
description: 'Description of the certificate',
|
|
||||||
editing: false,
|
|
||||||
id: 1622146085,
|
|
||||||
isActive: false,
|
|
||||||
name: 'Name of the certificate',
|
|
||||||
signatories: [
|
|
||||||
{
|
|
||||||
id: 268550145,
|
|
||||||
name: 'name_sign',
|
|
||||||
organization: 'org',
|
|
||||||
signatureImagePath: '/asset-v1:org+101+101+type@asset+block@camera.png',
|
|
||||||
title: 'title_sign',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
courseModes: ['honor', 'audit'],
|
|
||||||
hasCertificateModes: true,
|
|
||||||
isActive: false,
|
|
||||||
isGlobalStaff: true,
|
|
||||||
mfeProctoredExamSettingsUrl: '',
|
|
||||||
courseNumber: 'DemoX',
|
|
||||||
courseTitle: 'Demonstration Course',
|
|
||||||
courseNumberOverride: 'Course Number Display String',
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { default as certificatesDataMock } from './certificatesData';
|
|
||||||
export { default as signatoriesMock } from './signatories';
|
|
||||||
export { default as certificatesMock } from './certificates';
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
module.exports = [
|
|
||||||
{
|
|
||||||
id: '1', name: 'John Doe', title: 'CEO', organization: 'Company', signatureImagePath: '/path/to/signature1.png',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2', name: 'Jane Doe', title: 'CFO', organization: 'Company 2', signatureImagePath: '/path/to/signature2.png',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Card, Stack, Button } from '@openedx/paragon';
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Formik, Form, FieldArray } from 'formik';
|
|
||||||
|
|
||||||
import CertificateDetailsForm from '../certificate-details/CertificateDetailsForm';
|
|
||||||
import CertificateSignatories from '../certificate-signatories/CertificateSignatories';
|
|
||||||
import { defaultCertificate } from '../constants';
|
|
||||||
import messages from '../messages';
|
|
||||||
import useCertificateCreateForm from './hooks/useCertificateCreateForm';
|
|
||||||
|
|
||||||
const CertificateCreateForm = ({ courseId }) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const {
|
|
||||||
courseTitle, handleCertificateSubmit, handleFormCancel,
|
|
||||||
} = useCertificateCreateForm(courseId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Formik initialValues={defaultCertificate} onSubmit={handleCertificateSubmit}>
|
|
||||||
{({
|
|
||||||
values, handleChange, handleBlur, resetForm, setFieldValue,
|
|
||||||
}) => (
|
|
||||||
<Form className="certificates-card-form" data-testid="certificates-create-form">
|
|
||||||
<Card>
|
|
||||||
<Card.Section>
|
|
||||||
<Stack gap="4">
|
|
||||||
<CertificateDetailsForm
|
|
||||||
courseTitleOverride={values.courseTitle}
|
|
||||||
detailsCourseTitle={courseTitle}
|
|
||||||
handleChange={handleChange}
|
|
||||||
handleBlur={handleBlur}
|
|
||||||
/>
|
|
||||||
<FieldArray
|
|
||||||
name="signatories"
|
|
||||||
render={arrayHelpers => (
|
|
||||||
<CertificateSignatories
|
|
||||||
isForm
|
|
||||||
signatories={values.signatories}
|
|
||||||
arrayHelpers={arrayHelpers}
|
|
||||||
handleChange={handleChange}
|
|
||||||
handleBlur={handleBlur}
|
|
||||||
setFieldValue={setFieldValue}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Card.Section>
|
|
||||||
<Card.Footer className="justify-content-start">
|
|
||||||
<Button type="submit">
|
|
||||||
{intl.formatMessage(messages.cardCreate)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="tertiary"
|
|
||||||
onClick={() => handleFormCancel(resetForm)}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.cardCancel)}
|
|
||||||
</Button>
|
|
||||||
</Card.Footer>
|
|
||||||
</Card>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CertificateCreateForm.propTypes = {
|
|
||||||
courseId: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CertificateCreateForm;
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import { render, waitFor, within } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
|
|
||||||
import { executeThunk } from '../../utils';
|
|
||||||
import initializeStore from '../../store';
|
|
||||||
import { MODE_STATES } from '../data/constants';
|
|
||||||
import { getCertificatesApiUrl, getCertificateApiUrl } from '../data/api';
|
|
||||||
import { fetchCertificates, createCourseCertificate } from '../data/thunks';
|
|
||||||
import { certificatesDataMock } from '../__mocks__';
|
|
||||||
import detailsMessages from '../certificate-details/messages';
|
|
||||||
import signatoryMessages from '../certificate-signatories/messages';
|
|
||||||
import messages from '../messages';
|
|
||||||
import CertificateCreateForm from './CertificateCreateForm';
|
|
||||||
|
|
||||||
const courseId = 'course-123';
|
|
||||||
let store;
|
|
||||||
let axiosMock;
|
|
||||||
|
|
||||||
const renderComponent = () => render(
|
|
||||||
<Provider store={store}>
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<CertificateCreateForm courseId={courseId} />
|
|
||||||
</IntlProvider>
|
|
||||||
</Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
certificates: {
|
|
||||||
certificatesData: {
|
|
||||||
certificates: [],
|
|
||||||
hasCertificateModes: true,
|
|
||||||
},
|
|
||||||
componentMode: MODE_STATES.create,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('CertificateCreateForm', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore(initialState);
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCertificatesApiUrl(courseId))
|
|
||||||
.reply(200, {
|
|
||||||
...certificatesDataMock,
|
|
||||||
certificates: [],
|
|
||||||
});
|
|
||||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders with empty fields', () => {
|
|
||||||
const { getByPlaceholderText } = renderComponent();
|
|
||||||
|
|
||||||
expect(getByPlaceholderText(detailsMessages.detailsCourseTitleOverride.defaultMessage).value).toBe('');
|
|
||||||
expect(getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage).value).toBe('');
|
|
||||||
expect(getByPlaceholderText(signatoryMessages.titlePlaceholder.defaultMessage).value).toBe('');
|
|
||||||
expect(getByPlaceholderText(signatoryMessages.organizationPlaceholder.defaultMessage).value).toBe('');
|
|
||||||
expect(getByPlaceholderText(signatoryMessages.imagePlaceholder.defaultMessage).value).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates a new certificate', async () => {
|
|
||||||
const courseTitleOverrideValue = 'Create Course Title';
|
|
||||||
const signatoryNameValue = 'Create signatory name';
|
|
||||||
const newCertificateData = {
|
|
||||||
...certificatesDataMock,
|
|
||||||
courseTitle: courseTitleOverrideValue,
|
|
||||||
certificates: [{
|
|
||||||
...certificatesDataMock.certificates[0],
|
|
||||||
signatories: [{
|
|
||||||
...certificatesDataMock.certificates[0].signatories[0],
|
|
||||||
name: signatoryNameValue,
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { getByPlaceholderText, getByRole, getByDisplayValue } = renderComponent();
|
|
||||||
|
|
||||||
userEvent.type(
|
|
||||||
getByPlaceholderText(detailsMessages.detailsCourseTitleOverride.defaultMessage),
|
|
||||||
courseTitleOverrideValue,
|
|
||||||
);
|
|
||||||
userEvent.type(
|
|
||||||
getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage),
|
|
||||||
signatoryNameValue,
|
|
||||||
);
|
|
||||||
userEvent.click(getByRole('button', { name: messages.cardCreate.defaultMessage }));
|
|
||||||
|
|
||||||
axiosMock.onPost(
|
|
||||||
getCertificateApiUrl(courseId),
|
|
||||||
).reply(200, newCertificateData);
|
|
||||||
await executeThunk(createCourseCertificate(courseId, newCertificateData), store.dispatch);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(getByDisplayValue(courseTitleOverrideValue)).toBeInTheDocument();
|
|
||||||
expect(getByDisplayValue(signatoryNameValue)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cancel certificates creation', async () => {
|
|
||||||
const { getByRole } = renderComponent();
|
|
||||||
userEvent.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.noCertificates);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('there is no delete signatory button if signatories length is less then 2', async () => {
|
|
||||||
const { queryAllByRole } = renderComponent();
|
|
||||||
const deleteIcons = queryAllByRole('button', { name: messages.deleteTooltip.defaultMessage });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(deleteIcons.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('add and delete signatory', async () => {
|
|
||||||
const {
|
|
||||||
getAllByRole, queryAllByRole, getByText, getByRole,
|
|
||||||
} = renderComponent();
|
|
||||||
|
|
||||||
const addSignatoryBtn = getByText(signatoryMessages.addSignatoryButton.defaultMessage);
|
|
||||||
|
|
||||||
userEvent.click(addSignatoryBtn);
|
|
||||||
|
|
||||||
const deleteIcons = getAllByRole('button', { name: messages.deleteTooltip.defaultMessage });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(deleteIcons.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
userEvent.click(deleteIcons[0]);
|
|
||||||
|
|
||||||
const confirModal = getByRole('dialog');
|
|
||||||
const deleteModalButton = within(confirModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage });
|
|
||||||
|
|
||||||
userEvent.click(deleteIcons[0]);
|
|
||||||
userEvent.click(deleteModalButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(queryAllByRole('button', { name: messages.deleteTooltip.defaultMessage }).length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useSelector, useDispatch } from 'react-redux';
|
|
||||||
|
|
||||||
import { MODE_STATES } from '../../data/constants';
|
|
||||||
import { getCourseTitle } from '../../data/selectors';
|
|
||||||
import { setMode } from '../../data/slice';
|
|
||||||
import { createCourseCertificate } from '../../data/thunks';
|
|
||||||
|
|
||||||
const useCertificateCreateForm = (courseId) => {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const courseTitle = useSelector(getCourseTitle);
|
|
||||||
|
|
||||||
const handleCertificateSubmit = (values) => {
|
|
||||||
const signatoriesWithoutIds = values.signatories.map(({ id, ...rest }) => rest);
|
|
||||||
const newValues = { ...values, signatories: signatoriesWithoutIds };
|
|
||||||
dispatch(createCourseCertificate(courseId, newValues));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormCancel = (resetForm) => {
|
|
||||||
dispatch(setMode(MODE_STATES.noCertificates));
|
|
||||||
resetForm();
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
courseTitle, handleCertificateSubmit, handleFormCancel,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useCertificateCreateForm;
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
import {
|
|
||||||
Icon, Stack, IconButtonWithTooltip,
|
|
||||||
} from '@openedx/paragon';
|
|
||||||
import {
|
|
||||||
EditOutline as EditOutlineIcon, DeleteOutline as DeleteOutlineIcon,
|
|
||||||
} from '@openedx/paragon/icons';
|
|
||||||
|
|
||||||
import CertificateSection from '../certificate-section/CertificateSection';
|
|
||||||
import ModalNotification from '../../generic/modal-notification';
|
|
||||||
import commonMessages from '../messages';
|
|
||||||
import messages from './messages';
|
|
||||||
import useCertificateDetails from './hooks/useCertificateDetails';
|
|
||||||
|
|
||||||
const CertificateDetails = ({
|
|
||||||
certificateId,
|
|
||||||
detailsCourseTitle,
|
|
||||||
courseTitleOverride,
|
|
||||||
detailsCourseNumber,
|
|
||||||
courseNumberOverride,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const {
|
|
||||||
isConfirmOpen,
|
|
||||||
confirmOpen,
|
|
||||||
confirmClose,
|
|
||||||
isEditModalOpen,
|
|
||||||
editModalOpen,
|
|
||||||
editModalClose,
|
|
||||||
isCertificateActive,
|
|
||||||
handleEditAll,
|
|
||||||
handleDeleteCard,
|
|
||||||
} = useCertificateDetails(certificateId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CertificateSection
|
|
||||||
title={intl.formatMessage(messages.detailsSectionTitle)}
|
|
||||||
className="certificate-details"
|
|
||||||
data-testid="certificate-details"
|
|
||||||
actions={(
|
|
||||||
<Stack direction="horizontal" gap="2">
|
|
||||||
<IconButtonWithTooltip
|
|
||||||
src={EditOutlineIcon}
|
|
||||||
iconAs={Icon}
|
|
||||||
tooltipContent={<div>{intl.formatMessage(commonMessages.editTooltip)}</div>}
|
|
||||||
alt={intl.formatMessage(commonMessages.editTooltip)}
|
|
||||||
onClick={isCertificateActive ? editModalOpen : handleEditAll}
|
|
||||||
/>
|
|
||||||
<IconButtonWithTooltip
|
|
||||||
src={DeleteOutlineIcon}
|
|
||||||
iconAs={Icon}
|
|
||||||
tooltipContent={<div>{intl.formatMessage(commonMessages.deleteTooltip)}</div>}
|
|
||||||
alt={intl.formatMessage(commonMessages.deleteTooltip)}
|
|
||||||
onClick={confirmOpen}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<Stack direction="horizontal" gap="1.5" className="certificate-details__info">
|
|
||||||
<p className="certificate-details__info-paragraph">
|
|
||||||
<strong>{intl.formatMessage(messages.detailsCourseTitle)}:</strong> {detailsCourseTitle}
|
|
||||||
</p>
|
|
||||||
<p className="certificate-details__info-paragraph-course-number">
|
|
||||||
<strong>{intl.formatMessage(messages.detailsCourseNumber)}:</strong> {detailsCourseNumber}
|
|
||||||
</p>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="horizontal" gap="1.5" className="certificate-details__info">
|
|
||||||
{courseTitleOverride && (
|
|
||||||
<p className="certificate-details__info-paragraph">
|
|
||||||
<strong>{intl.formatMessage(messages.detailsCourseTitleOverride)}:</strong> {courseTitleOverride}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{courseNumberOverride && (
|
|
||||||
<p className="certificate-details__info-paragraph text-right">
|
|
||||||
<strong>{intl.formatMessage(messages.detailsCourseNumberOverride)}:</strong> {courseNumberOverride}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
<ModalNotification
|
|
||||||
isOpen={isEditModalOpen}
|
|
||||||
title={intl.formatMessage(messages.editCertificateConfirmationTitle)}
|
|
||||||
message={intl.formatMessage(messages.editCertificateMessage)}
|
|
||||||
actionButtonText={intl.formatMessage(commonMessages.editTooltip)}
|
|
||||||
cancelButtonText={intl.formatMessage(commonMessages.cardCancel)}
|
|
||||||
handleCancel={editModalClose}
|
|
||||||
handleAction={() => {
|
|
||||||
editModalClose();
|
|
||||||
handleEditAll();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ModalNotification
|
|
||||||
isOpen={isConfirmOpen}
|
|
||||||
title={intl.formatMessage(messages.deleteCertificateConfirmationTitle)}
|
|
||||||
message={intl.formatMessage(messages.deleteCertificateMessage)}
|
|
||||||
actionButtonText={intl.formatMessage(commonMessages.deleteTooltip)}
|
|
||||||
cancelButtonText={intl.formatMessage(commonMessages.cardCancel)}
|
|
||||||
handleCancel={confirmClose}
|
|
||||||
handleAction={() => {
|
|
||||||
confirmClose();
|
|
||||||
handleDeleteCard();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</CertificateSection>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CertificateDetails.defaultProps = {
|
|
||||||
courseTitleOverride: '',
|
|
||||||
courseNumberOverride: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
CertificateDetails.propTypes = {
|
|
||||||
certificateId: PropTypes.number.isRequired,
|
|
||||||
courseTitleOverride: PropTypes.string,
|
|
||||||
courseNumberOverride: PropTypes.string,
|
|
||||||
detailsCourseTitle: PropTypes.string.isRequired,
|
|
||||||
detailsCourseNumber: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CertificateDetails;
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { Provider, useDispatch } from 'react-redux';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import { render, waitFor } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
|
|
||||||
import initializeStore from '../../store';
|
|
||||||
import { MODE_STATES } from '../data/constants';
|
|
||||||
import { deleteCourseCertificate } from '../data/thunks';
|
|
||||||
import commonMessages from '../messages';
|
|
||||||
import messages from './messages';
|
|
||||||
import CertificateDetails from './CertificateDetails';
|
|
||||||
|
|
||||||
let store;
|
|
||||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
|
||||||
const certificateId = 123;
|
|
||||||
|
|
||||||
jest.mock('react-redux', () => ({
|
|
||||||
...jest.requireActual('react-redux'),
|
|
||||||
useDispatch: jest.fn(),
|
|
||||||
useSelector: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
|
||||||
...jest.requireActual('react-router-dom'),
|
|
||||||
useParams: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../data/thunks', () => ({
|
|
||||||
deleteCourseCertificate: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const renderComponent = (props) => render(
|
|
||||||
<Provider store={store}>
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<CertificateDetails {...props} />
|
|
||||||
</IntlProvider>
|
|
||||||
</Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
componentMode: MODE_STATES.view,
|
|
||||||
detailsCourseTitle: 'Course Title',
|
|
||||||
detailsCourseNumber: 'Course Number',
|
|
||||||
handleChange: jest.fn(),
|
|
||||||
handleBlur: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
certificates: {
|
|
||||||
certificatesData: {
|
|
||||||
certificates: [],
|
|
||||||
hasCertificateModes: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('CertificateDetails', () => {
|
|
||||||
let mockDispatch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore(initialState);
|
|
||||||
useParams.mockReturnValue({ courseId });
|
|
||||||
mockDispatch = jest.fn();
|
|
||||||
useDispatch.mockReturnValue(mockDispatch);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
useParams.mockClear();
|
|
||||||
mockDispatch.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders correctly in view mode', () => {
|
|
||||||
const { getByText } = renderComponent(defaultProps);
|
|
||||||
|
|
||||||
expect(getByText(messages.detailsSectionTitle.defaultMessage)).toBeInTheDocument();
|
|
||||||
expect(getByText(defaultProps.detailsCourseTitle)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opens confirm modal on delete button click', () => {
|
|
||||||
const { getByRole, getByText } = renderComponent(defaultProps);
|
|
||||||
const deleteButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
|
||||||
userEvent.click(deleteButton);
|
|
||||||
|
|
||||||
expect(getByText(messages.deleteCertificateConfirmationTitle.defaultMessage)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('dispatches delete action on confirm modal action', async () => {
|
|
||||||
const props = { ...defaultProps, courseId, certificateId };
|
|
||||||
const { getByRole } = renderComponent(props);
|
|
||||||
const deleteButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
|
||||||
userEvent.click(deleteButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const confirmActionButton = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
|
||||||
userEvent.click(confirmActionButton);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockDispatch).toHaveBeenCalledWith(deleteCourseCertificate(courseId, certificateId));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows course title override in view mode', () => {
|
|
||||||
const courseTitleOverride = 'Overridden Title';
|
|
||||||
const props = { ...defaultProps, courseTitleOverride };
|
|
||||||
const { getByText } = renderComponent(props);
|
|
||||||
|
|
||||||
expect(getByText(courseTitleOverride)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Stack, Form } from '@openedx/paragon';
|
|
||||||
|
|
||||||
import CertificateSection from '../certificate-section/CertificateSection';
|
|
||||||
import messages from './messages';
|
|
||||||
|
|
||||||
const CertificateDetailsForm = ({
|
|
||||||
detailsCourseTitle,
|
|
||||||
courseTitleOverride,
|
|
||||||
handleChange,
|
|
||||||
handleBlur,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
return (
|
|
||||||
<CertificateSection
|
|
||||||
title={intl.formatMessage(messages.detailsSectionTitle)}
|
|
||||||
className="certificate-details"
|
|
||||||
data-testid="certificate-details-form"
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<Stack direction="horizontal" gap="1.5" className="certificate-details__info">
|
|
||||||
<p className="certificate-details__info-paragraph">
|
|
||||||
<strong>{intl.formatMessage(messages.detailsCourseTitle)}:</strong> {detailsCourseTitle}
|
|
||||||
</p>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="horizontal" gap="1.5" className="certificate-details__info">
|
|
||||||
<Form.Group className="m-0 w-100">
|
|
||||||
<Form.Label>{intl.formatMessage(messages.detailsCourseTitleOverride)}</Form.Label>
|
|
||||||
<Form.Control
|
|
||||||
name="courseTitle"
|
|
||||||
value={courseTitleOverride}
|
|
||||||
onChange={handleChange}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder={intl.formatMessage(messages.detailsCourseTitleOverride)}
|
|
||||||
/>
|
|
||||||
<Form.Control.Feedback>
|
|
||||||
<span className="x-small">{intl.formatMessage(messages.detailsCourseTitleOverrideDescription)}</span>
|
|
||||||
</Form.Control.Feedback>
|
|
||||||
</Form.Group>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</CertificateSection>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CertificateDetailsForm.propTypes = {
|
|
||||||
courseTitleOverride: PropTypes.string.isRequired,
|
|
||||||
detailsCourseTitle: PropTypes.string.isRequired,
|
|
||||||
handleChange: PropTypes.func.isRequired,
|
|
||||||
handleBlur: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CertificateDetailsForm;
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { Provider } from 'react-redux';
|
|
||||||
import { render, waitFor } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
|
|
||||||
import initializeStore from '../../store';
|
|
||||||
import { MODE_STATES } from '../data/constants';
|
|
||||||
import commonMessages from '../messages';
|
|
||||||
import messages from './messages';
|
|
||||||
import CertificateDetailsForm from './CertificateDetailsForm';
|
|
||||||
|
|
||||||
let store;
|
|
||||||
|
|
||||||
const renderComponent = (props) => render(
|
|
||||||
<Provider store={store}>
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<CertificateDetailsForm {...props} />
|
|
||||||
</IntlProvider>
|
|
||||||
</Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
componentMode: MODE_STATES.view,
|
|
||||||
detailsCourseTitle: 'Course Title',
|
|
||||||
detailsCourseNumber: 'Course Number',
|
|
||||||
handleChange: jest.fn(),
|
|
||||||
handleBlur: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
certificates: {
|
|
||||||
certificatesData: {
|
|
||||||
certificates: [],
|
|
||||||
hasCertificateModes: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('CertificateDetails', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore(initialState);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders correctly in create mode', () => {
|
|
||||||
const { getByText, getByPlaceholderText } = renderComponent(defaultProps);
|
|
||||||
|
|
||||||
expect(getByText(messages.detailsSectionTitle.defaultMessage)).toBeInTheDocument();
|
|
||||||
expect(getByPlaceholderText(messages.detailsCourseTitleOverride.defaultMessage)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles input change in create mode', async () => {
|
|
||||||
const { getByPlaceholderText } = renderComponent(defaultProps);
|
|
||||||
const input = getByPlaceholderText(messages.detailsCourseTitleOverride.defaultMessage);
|
|
||||||
const newInputValue = 'New Title';
|
|
||||||
|
|
||||||
userEvent.type(input, newInputValue);
|
|
||||||
|
|
||||||
waitFor(() => {
|
|
||||||
expect(input.value).toBe(newInputValue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not show delete button in create mode', () => {
|
|
||||||
const { queryByRole } = renderComponent(defaultProps);
|
|
||||||
|
|
||||||
expect(queryByRole('button', { name: commonMessages.deleteTooltip.defaultMessage })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import { useToggle } from '@openedx/paragon';
|
|
||||||
|
|
||||||
import { setMode } from '../../data/slice';
|
|
||||||
import { deleteCourseCertificate } from '../../data/thunks';
|
|
||||||
import { getIsCertificateActive } from '../../data/selectors';
|
|
||||||
import { MODE_STATES } from '../../data/constants';
|
|
||||||
|
|
||||||
const useCertificateDetails = (certificateId) => {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const { courseId } = useParams();
|
|
||||||
const [isConfirmOpen, confirmOpen, confirmClose] = useToggle(false);
|
|
||||||
const [isEditModalOpen, editModalOpen, editModalClose] = useToggle(false);
|
|
||||||
const isCertificateActive = useSelector(getIsCertificateActive);
|
|
||||||
|
|
||||||
const handleEditAll = () => {
|
|
||||||
dispatch(setMode(MODE_STATES.editAll));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteCard = () => {
|
|
||||||
if (certificateId) {
|
|
||||||
dispatch(deleteCourseCertificate(courseId, certificateId));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
isConfirmOpen,
|
|
||||||
confirmOpen,
|
|
||||||
confirmClose,
|
|
||||||
isEditModalOpen,
|
|
||||||
editModalOpen,
|
|
||||||
editModalClose,
|
|
||||||
isCertificateActive,
|
|
||||||
handleEditAll,
|
|
||||||
handleDeleteCard,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useCertificateDetails;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
detailsSectionTitle: {
|
|
||||||
id: 'course-authoring.certificates.details.section.title',
|
|
||||||
defaultMessage: 'Certificate details',
|
|
||||||
description: 'Title for the section',
|
|
||||||
},
|
|
||||||
detailsCourseTitle: {
|
|
||||||
id: 'course-authoring.certificates.details.course.title',
|
|
||||||
defaultMessage: 'Course title',
|
|
||||||
description: 'Label for displaying the official course title in the certificate details section',
|
|
||||||
},
|
|
||||||
detailsCourseTitleOverride: {
|
|
||||||
id: 'course-authoring.certificates.details.course.title.override',
|
|
||||||
defaultMessage: 'Course title override',
|
|
||||||
description: 'Label for the course title override input field',
|
|
||||||
},
|
|
||||||
detailsCourseTitleOverrideDescription: {
|
|
||||||
id: 'course-authoring.certificates.details.course.title.override.description',
|
|
||||||
defaultMessage: 'Specify an alternative to the official course title to display on certificates. Leave blank to use the official course title.',
|
|
||||||
description: 'Helper text under the course title override input field',
|
|
||||||
},
|
|
||||||
detailsCourseNumber: {
|
|
||||||
id: 'course-authoring.certificates.details.course.number',
|
|
||||||
defaultMessage: 'Course number',
|
|
||||||
description: 'Label for displaying the official course number in the certificate details section',
|
|
||||||
},
|
|
||||||
detailsCourseNumberOverride: {
|
|
||||||
id: 'course-authoring.certificates.details.course.number.override',
|
|
||||||
defaultMessage: 'Course number override',
|
|
||||||
description: 'Label for the course number override input field',
|
|
||||||
},
|
|
||||||
deleteCertificateConfirmationTitle: {
|
|
||||||
id: 'course-authoring.certificates.details.confirm-modal',
|
|
||||||
defaultMessage: 'Delete this certificate?',
|
|
||||||
description: 'Title for the confirmation modal when a user attempts to delete a certificate',
|
|
||||||
},
|
|
||||||
deleteCertificateMessage: {
|
|
||||||
id: 'course-authoring.certificates.details.confirm-modal.message',
|
|
||||||
defaultMessage: 'Deleting this certificate is permanent and cannot be undone.',
|
|
||||||
description: 'Warning message within the delete confirmation modal, emphasizing the permanent nature of the action',
|
|
||||||
},
|
|
||||||
editCertificateConfirmationTitle: {
|
|
||||||
id: 'course-authoring.certificates.details.confirm.edit',
|
|
||||||
defaultMessage: 'Edit this certificate?',
|
|
||||||
description: 'Title for the confirmation modal when a user attempts to edit an already activated (live) certificate',
|
|
||||||
},
|
|
||||||
editCertificateMessage: {
|
|
||||||
id: 'course-authoring.certificates.details.confirm.edit.message',
|
|
||||||
defaultMessage: 'This certificate has already been activated and is live. Are you sure you want to continue editing?',
|
|
||||||
description: 'Message warning users about the implications of editing a certificate that is already live, prompting for confirmation',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default messages;
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Card, Stack, Button } from '@openedx/paragon';
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Formik, Form, FieldArray } from 'formik';
|
|
||||||
|
|
||||||
import ModalNotification from '../../generic/modal-notification';
|
|
||||||
import CertificateDetailsForm from '../certificate-details/CertificateDetailsForm';
|
|
||||||
import CertificateSignatories from '../certificate-signatories/CertificateSignatories';
|
|
||||||
import commonMessages from '../messages';
|
|
||||||
import messages from '../certificate-details/messages';
|
|
||||||
import useCertificateEditForm from './hooks/useCertificateEditForm';
|
|
||||||
|
|
||||||
const CertificateEditForm = ({ courseId }) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const {
|
|
||||||
confirmOpen,
|
|
||||||
courseTitle,
|
|
||||||
certificates,
|
|
||||||
confirmClose,
|
|
||||||
initialValues,
|
|
||||||
isConfirmOpen,
|
|
||||||
handleCertificateDelete,
|
|
||||||
handleCertificateSubmit,
|
|
||||||
handleCertificateUpdateCancel,
|
|
||||||
} = useCertificateEditForm(courseId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{certificates.map((certificate, id) => (
|
|
||||||
<Formik initialValues={initialValues[id]} onSubmit={handleCertificateSubmit} key={certificate.id}>
|
|
||||||
{({
|
|
||||||
values, handleChange, handleBlur, resetForm, setFieldValue,
|
|
||||||
}) => (
|
|
||||||
<>
|
|
||||||
<Form className="certificates-card-form" data-testid="certificates-edit-form">
|
|
||||||
<Card>
|
|
||||||
<Card.Section>
|
|
||||||
<Stack gap="4">
|
|
||||||
<CertificateDetailsForm
|
|
||||||
courseTitleOverride={values.courseTitle}
|
|
||||||
detailsCourseTitle={courseTitle}
|
|
||||||
handleChange={handleChange}
|
|
||||||
handleBlur={handleBlur}
|
|
||||||
/>
|
|
||||||
<FieldArray
|
|
||||||
name="signatories"
|
|
||||||
render={arrayHelpers => (
|
|
||||||
<CertificateSignatories
|
|
||||||
isForm
|
|
||||||
signatories={values.signatories}
|
|
||||||
arrayHelpers={arrayHelpers}
|
|
||||||
handleChange={handleChange}
|
|
||||||
handleBlur={handleBlur}
|
|
||||||
setFieldValue={setFieldValue}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Card.Section>
|
|
||||||
<Card.Footer className="justify-content-start">
|
|
||||||
<Button type="submit">
|
|
||||||
{intl.formatMessage(commonMessages.saveTooltip)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline-primary"
|
|
||||||
onClick={() => handleCertificateUpdateCancel(resetForm)}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(commonMessages.cardCancel)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="ml-auto"
|
|
||||||
variant="tertiary"
|
|
||||||
onClick={() => confirmOpen(certificate.id)}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(commonMessages.deleteTooltip)}
|
|
||||||
</Button>
|
|
||||||
</Card.Footer>
|
|
||||||
</Card>
|
|
||||||
</Form>
|
|
||||||
<ModalNotification
|
|
||||||
isOpen={isConfirmOpen}
|
|
||||||
title={intl.formatMessage(messages.deleteCertificateConfirmationTitle)}
|
|
||||||
message={intl.formatMessage(messages.deleteCertificateMessage)}
|
|
||||||
actionButtonText={intl.formatMessage(commonMessages.deleteTooltip)}
|
|
||||||
cancelButtonText={intl.formatMessage(commonMessages.cardCancel)}
|
|
||||||
handleCancel={() => confirmClose()}
|
|
||||||
handleAction={() => {
|
|
||||||
confirmClose();
|
|
||||||
handleCertificateDelete(certificate.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CertificateEditForm.propTypes = {
|
|
||||||
courseId: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CertificateEditForm;
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { Provider } from 'react-redux';
|
|
||||||
import { render, waitFor, within } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
|
|
||||||
import { RequestStatus } from '../../data/constants';
|
|
||||||
import { executeThunk } from '../../utils';
|
|
||||||
import initializeStore from '../../store';
|
|
||||||
import { getCertificatesApiUrl, getUpdateCertificateApiUrl } from '../data/api';
|
|
||||||
import { fetchCertificates, deleteCourseCertificate, updateCourseCertificate } from '../data/thunks';
|
|
||||||
import { certificatesDataMock } from '../__mocks__';
|
|
||||||
import { MODE_STATES } from '../data/constants';
|
|
||||||
import messagesDetails from '../certificate-details/messages';
|
|
||||||
import messages from '../messages';
|
|
||||||
import CertificateEditForm from './CertificateEditForm';
|
|
||||||
|
|
||||||
let axiosMock;
|
|
||||||
let store;
|
|
||||||
const courseId = 'course-123';
|
|
||||||
|
|
||||||
const renderComponent = () => render(
|
|
||||||
<Provider store={store}>
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<CertificateEditForm courseId="course-123" />
|
|
||||||
</IntlProvider>
|
|
||||||
</Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
certificates: {
|
|
||||||
certificatesData: {},
|
|
||||||
componentMode: MODE_STATES.editAll,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('CertificateEditForm Component', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore(initialState);
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCertificatesApiUrl(courseId))
|
|
||||||
.reply(200, certificatesDataMock);
|
|
||||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('submits the form with updated certificate details', async () => {
|
|
||||||
const courseTitleOverrideValue = 'Updated Course Title';
|
|
||||||
const signatoryNameValue = 'Updated signatory name';
|
|
||||||
const newCertificateData = {
|
|
||||||
...certificatesDataMock,
|
|
||||||
courseTitle: courseTitleOverrideValue,
|
|
||||||
certificates: [{
|
|
||||||
...certificatesDataMock.certificates[0],
|
|
||||||
signatories: [{
|
|
||||||
...certificatesDataMock.certificates[0].signatories[0],
|
|
||||||
name: signatoryNameValue,
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { getByDisplayValue, getByRole, getByPlaceholderText } = renderComponent();
|
|
||||||
|
|
||||||
userEvent.type(
|
|
||||||
getByPlaceholderText(messagesDetails.detailsCourseTitleOverride.defaultMessage),
|
|
||||||
courseTitleOverrideValue,
|
|
||||||
);
|
|
||||||
|
|
||||||
userEvent.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
|
|
||||||
|
|
||||||
axiosMock.onPost(
|
|
||||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
|
||||||
).reply(200, newCertificateData);
|
|
||||||
await executeThunk(updateCourseCertificate(courseId, newCertificateData), store.dispatch);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(getByDisplayValue(
|
|
||||||
certificatesDataMock.certificates[0].courseTitle + courseTitleOverrideValue,
|
|
||||||
)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes a certificate and updates the store', async () => {
|
|
||||||
axiosMock.onDelete(
|
|
||||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
|
||||||
).reply(200);
|
|
||||||
|
|
||||||
const { getByRole } = renderComponent();
|
|
||||||
|
|
||||||
userEvent.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
|
||||||
|
|
||||||
const confirmDeleteModal = getByRole('dialog');
|
|
||||||
userEvent.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
|
||||||
|
|
||||||
await executeThunk(deleteCourseCertificate(courseId, certificatesDataMock.certificates[0].id), store.dispatch);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(store.getState().certificates.certificatesData.certificates.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates loading status if delete fails', async () => {
|
|
||||||
axiosMock.onDelete(
|
|
||||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
|
||||||
).reply(404);
|
|
||||||
|
|
||||||
const { getByRole } = renderComponent();
|
|
||||||
|
|
||||||
userEvent.click(getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
|
||||||
|
|
||||||
const confirmDeleteModal = getByRole('dialog');
|
|
||||||
userEvent.click(within(confirmDeleteModal).getByRole('button', { name: messages.deleteTooltip.defaultMessage }));
|
|
||||||
|
|
||||||
await executeThunk(deleteCourseCertificate(courseId, certificatesDataMock.certificates[0].id), store.dispatch);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(store.getState().certificates.savingStatus).toBe(RequestStatus.FAILED);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cancel edit form', async () => {
|
|
||||||
const { getByRole } = renderComponent();
|
|
||||||
|
|
||||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.editAll);
|
|
||||||
|
|
||||||
userEvent.click(getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
|
||||||
|
|
||||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.view);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { useSelector, useDispatch } from 'react-redux';
|
|
||||||
import { useToggle } from '@openedx/paragon';
|
|
||||||
|
|
||||||
import { MODE_STATES } from '../../data/constants';
|
|
||||||
import { getCourseTitle, getCertificates } from '../../data/selectors';
|
|
||||||
import { setMode } from '../../data/slice';
|
|
||||||
import { updateCourseCertificate, deleteCourseCertificate } from '../../data/thunks';
|
|
||||||
import { defaultCertificate } from '../../constants';
|
|
||||||
|
|
||||||
const useCertificateEditForm = (courseId) => {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const [isConfirmOpen, confirmOpen, confirmClose] = useToggle(false);
|
|
||||||
const courseTitle = useSelector(getCourseTitle);
|
|
||||||
const certificates = useSelector(getCertificates);
|
|
||||||
|
|
||||||
const handleCertificateSubmit = (values) => {
|
|
||||||
const signatoriesWithoutLocalIds = values.signatories.map(signatory => {
|
|
||||||
if (signatory.id && typeof signatory.id === 'string' && signatory.id.startsWith('local-')) {
|
|
||||||
const { id, ...rest } = signatory;
|
|
||||||
return rest;
|
|
||||||
}
|
|
||||||
return signatory;
|
|
||||||
});
|
|
||||||
|
|
||||||
const newValues = {
|
|
||||||
...values,
|
|
||||||
signatories: signatoriesWithoutLocalIds,
|
|
||||||
};
|
|
||||||
|
|
||||||
dispatch(updateCourseCertificate(courseId, newValues));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCertificateUpdateCancel = (resetForm) => {
|
|
||||||
dispatch(setMode(MODE_STATES.view));
|
|
||||||
resetForm();
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCertificateDelete = (certificateId) => {
|
|
||||||
dispatch(deleteCourseCertificate(courseId, certificateId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialValues = certificates.map((certificate) => ({
|
|
||||||
...certificate,
|
|
||||||
courseTitle: certificate.courseTitle || defaultCertificate.courseTitle,
|
|
||||||
signatories: certificate.signatories || defaultCertificate.signatories,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
confirmOpen,
|
|
||||||
courseTitle,
|
|
||||||
certificates,
|
|
||||||
confirmClose,
|
|
||||||
initialValues,
|
|
||||||
isConfirmOpen,
|
|
||||||
handleCertificateDelete,
|
|
||||||
handleCertificateSubmit,
|
|
||||||
handleCertificateUpdateCancel,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useCertificateEditForm;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Stack } from '@openedx/paragon';
|
|
||||||
|
|
||||||
const CertificateSection = ({
|
|
||||||
title, actions, children, ...rest
|
|
||||||
}) => (
|
|
||||||
<section {...rest}>
|
|
||||||
<Stack className="justify-content-between mb-2.5" direction="horizontal">
|
|
||||||
<h2 className="lead section-title mb-0">{title}</h2>
|
|
||||||
{actions && actions}
|
|
||||||
</Stack>
|
|
||||||
<hr className="mt-0 mb-4" />
|
|
||||||
<div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
|
|
||||||
CertificateSection.defaultProps = {
|
|
||||||
children: null,
|
|
||||||
actions: null,
|
|
||||||
};
|
|
||||||
CertificateSection.propTypes = {
|
|
||||||
children: PropTypes.node,
|
|
||||||
actions: PropTypes.node,
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CertificateSection;
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Stack, Button, Form } from '@openedx/paragon';
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
import CertificateSection from '../certificate-section/CertificateSection';
|
|
||||||
import Signatory from './signatory/Signatory';
|
|
||||||
import SignatoryForm from './signatory/SignatoryForm';
|
|
||||||
import useEditSignatory from './hooks/useEditSignatory';
|
|
||||||
import useCreateSignatory from './hooks/useCreateSignatory';
|
|
||||||
import messages from './messages';
|
|
||||||
|
|
||||||
const CertificateSignatories = ({
|
|
||||||
isForm,
|
|
||||||
editModes,
|
|
||||||
signatories,
|
|
||||||
arrayHelpers,
|
|
||||||
initialSignatoriesValues,
|
|
||||||
setFieldValue,
|
|
||||||
setEditModes,
|
|
||||||
handleBlur,
|
|
||||||
handleChange,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const {
|
|
||||||
toggleEditSignatory,
|
|
||||||
handleDeleteSignatory,
|
|
||||||
handleCancelUpdateSignatory,
|
|
||||||
} = useEditSignatory({
|
|
||||||
arrayHelpers, editModes, setEditModes, setFieldValue, initialSignatoriesValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { handleAddSignatory } = useCreateSignatory({ arrayHelpers });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CertificateSection
|
|
||||||
title={intl.formatMessage(messages.signatoriesSectionTitle)}
|
|
||||||
className="certificate-signatories"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="mb-4.5">
|
|
||||||
{intl.formatMessage(messages.signatoriesRecommendation)}
|
|
||||||
</p>
|
|
||||||
<Stack gap="4.5">
|
|
||||||
{signatories.map(({
|
|
||||||
id, name, title, organization, signatureImagePath,
|
|
||||||
}, idx) => (
|
|
||||||
isForm || editModes[idx] ? (
|
|
||||||
<SignatoryForm
|
|
||||||
key={id}
|
|
||||||
index={idx}
|
|
||||||
isEdit={editModes[idx]}
|
|
||||||
name={name}
|
|
||||||
title={title}
|
|
||||||
organization={organization}
|
|
||||||
signatureImagePath={signatureImagePath}
|
|
||||||
handleChange={handleChange}
|
|
||||||
handleBlur={handleBlur}
|
|
||||||
setFieldValue={setFieldValue}
|
|
||||||
showDeleteButton={signatories.length > 1 && !editModes[idx]}
|
|
||||||
handleDeleteSignatory={() => handleDeleteSignatory(idx)}
|
|
||||||
{...(editModes[idx] && {
|
|
||||||
handleCancelUpdateSignatory: () => handleCancelUpdateSignatory(idx),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Signatory
|
|
||||||
key={id}
|
|
||||||
index={idx}
|
|
||||||
name={name}
|
|
||||||
title={title}
|
|
||||||
organization={organization}
|
|
||||||
signatureImagePath={signatureImagePath}
|
|
||||||
handleEdit={() => toggleEditSignatory(idx)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
{isForm && (
|
|
||||||
<>
|
|
||||||
<Button variant="outline-primary" onClick={handleAddSignatory} className="w-100 mt-4">
|
|
||||||
{intl.formatMessage(messages.addSignatoryButton)}
|
|
||||||
</Button>
|
|
||||||
<Form.Control.Feedback>
|
|
||||||
<span className="x-small">{intl.formatMessage(messages.addSignatoryButtonDescription)}</span>
|
|
||||||
</Form.Control.Feedback>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CertificateSection>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CertificateSignatories.defaultProps = {
|
|
||||||
handleChange: null,
|
|
||||||
handleBlur: null,
|
|
||||||
setFieldValue: null,
|
|
||||||
arrayHelpers: null,
|
|
||||||
isForm: false,
|
|
||||||
editModes: {},
|
|
||||||
setEditModes: null,
|
|
||||||
initialSignatoriesValues: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
CertificateSignatories.propTypes = {
|
|
||||||
isForm: PropTypes.bool,
|
|
||||||
editModes: PropTypes.objectOf(PropTypes.bool),
|
|
||||||
initialSignatoriesValues: PropTypes.arrayOf(PropTypes.shape({
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
organization: PropTypes.string.isRequired,
|
|
||||||
signatureImagePath: PropTypes.string.isRequired,
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
})),
|
|
||||||
handleChange: PropTypes.func,
|
|
||||||
handleBlur: PropTypes.func,
|
|
||||||
setFieldValue: PropTypes.func,
|
|
||||||
setEditModes: PropTypes.func,
|
|
||||||
arrayHelpers: PropTypes.shape({
|
|
||||||
push: PropTypes.func,
|
|
||||||
remove: PropTypes.func,
|
|
||||||
}),
|
|
||||||
signatories: PropTypes.arrayOf(PropTypes.shape({
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
organization: PropTypes.string.isRequired,
|
|
||||||
signatureImagePath: PropTypes.string.isRequired,
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
})).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CertificateSignatories;
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { render, waitFor } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
|
|
||||||
import initializeStore from '../../store';
|
|
||||||
import { MODE_STATES } from '../data/constants';
|
|
||||||
import { signatoriesMock } from '../__mocks__';
|
|
||||||
import commonMessages from '../messages';
|
|
||||||
import messages from './messages';
|
|
||||||
import useEditSignatory from './hooks/useEditSignatory';
|
|
||||||
import useCreateSignatory from './hooks/useCreateSignatory';
|
|
||||||
import CertificateSignatories from './CertificateSignatories';
|
|
||||||
|
|
||||||
let store;
|
|
||||||
|
|
||||||
const mockArrayHelpers = {
|
|
||||||
push: jest.fn(),
|
|
||||||
remove: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.mock('./hooks/useEditSignatory');
|
|
||||||
|
|
||||||
jest.mock('./hooks/useCreateSignatory');
|
|
||||||
|
|
||||||
const renderComponent = (props) => render(
|
|
||||||
<Provider store={store}>
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<CertificateSignatories {...props} />
|
|
||||||
</IntlProvider>,
|
|
||||||
</Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
signatories: signatoriesMock,
|
|
||||||
handleChange: jest.fn(),
|
|
||||||
handleBlur: jest.fn(),
|
|
||||||
setFieldValue: jest.fn(),
|
|
||||||
arrayHelpers: mockArrayHelpers,
|
|
||||||
isForm: true,
|
|
||||||
resetForm: jest.fn(),
|
|
||||||
editModes: {},
|
|
||||||
setEditModes: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
certificates: {
|
|
||||||
certificatesData: {
|
|
||||||
certificates: [],
|
|
||||||
hasCertificateModes: true,
|
|
||||||
},
|
|
||||||
componentMode: MODE_STATES.create,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('CertificateSignatories', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore(initialState);
|
|
||||||
useEditSignatory.mockReturnValue({
|
|
||||||
toggleEditSignatory: jest.fn(),
|
|
||||||
handleDeleteSignatory: jest.fn(),
|
|
||||||
handleCancelUpdateSignatory: jest.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
useCreateSignatory.mockReturnValue({
|
|
||||||
handleAddSignatory: jest.fn(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => jest.clearAllMocks());
|
|
||||||
|
|
||||||
it('renders signatory components for each signatory', () => {
|
|
||||||
const { getByText } = renderComponent({ ...defaultProps, isForm: false });
|
|
||||||
|
|
||||||
signatoriesMock.forEach(signatory => {
|
|
||||||
expect(getByText(signatory.name)).toBeInTheDocument();
|
|
||||||
expect(getByText(signatory.title)).toBeInTheDocument();
|
|
||||||
expect(getByText(signatory.organization)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adds a new signatory when add button is clicked', () => {
|
|
||||||
const { getByText } = renderComponent({ ...defaultProps, isForm: true });
|
|
||||||
|
|
||||||
userEvent.click(getByText(messages.addSignatoryButton.defaultMessage));
|
|
||||||
expect(useCreateSignatory().handleAddSignatory).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls remove for the correct signatory when delete icon is clicked', async () => {
|
|
||||||
const { getAllByRole } = renderComponent(defaultProps);
|
|
||||||
|
|
||||||
const deleteIcons = getAllByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
|
||||||
expect(deleteIcons.length).toBe(signatoriesMock.length);
|
|
||||||
|
|
||||||
userEvent.click(deleteIcons[0]);
|
|
||||||
|
|
||||||
waitFor(() => {
|
|
||||||
expect(mockArrayHelpers.remove).toHaveBeenCalledWith(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
|
|
||||||
const useCreateSignatory = ({ arrayHelpers }) => {
|
|
||||||
const handleAddSignatory = () => {
|
|
||||||
const getNewSignatory = () => ({
|
|
||||||
id: `local-${uuid()}`, name: '', title: '', organization: '', signatureImagePath: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
arrayHelpers.push(getNewSignatory());
|
|
||||||
};
|
|
||||||
|
|
||||||
return { handleAddSignatory };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useCreateSignatory;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
const useEditSignatory = ({
|
|
||||||
arrayHelpers, editModes, setEditModes, setFieldValue, initialSignatoriesValues,
|
|
||||||
}) => {
|
|
||||||
const handleDeleteSignatory = (id) => {
|
|
||||||
arrayHelpers.remove(id);
|
|
||||||
|
|
||||||
if (editModes && setEditModes) {
|
|
||||||
const newEditModes = { ...editModes };
|
|
||||||
delete newEditModes[id];
|
|
||||||
setEditModes(newEditModes);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleEditSignatory = (id) => {
|
|
||||||
setEditModes(prev => ({
|
|
||||||
...prev,
|
|
||||||
[id]: !prev[id],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelUpdateSignatory = (id) => {
|
|
||||||
const signatoryInitialValues = initialSignatoriesValues[id];
|
|
||||||
Object.keys(signatoryInitialValues).forEach(fieldKey => {
|
|
||||||
const fieldName = `signatories[${id}].${fieldKey}`;
|
|
||||||
setFieldValue(fieldName, signatoryInitialValues[fieldKey]);
|
|
||||||
});
|
|
||||||
toggleEditSignatory(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return { toggleEditSignatory, handleDeleteSignatory, handleCancelUpdateSignatory };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useEditSignatory;
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
signatoryTitle: {
|
|
||||||
id: 'course-authoring.certificates.signatories.title',
|
|
||||||
defaultMessage: 'Signatory',
|
|
||||||
description: 'Title for a signatory',
|
|
||||||
},
|
|
||||||
signatoriesRecommendation: {
|
|
||||||
id: 'course-authoring.certificates.signatories.recommendation',
|
|
||||||
defaultMessage: 'It is strongly recommended that you include four or fewer signatories. If you include additional signatories, preview the certificate in Print View to ensure the certificate will print correctly on one page.',
|
|
||||||
description: 'A recommendation for the number of signatories to include on a certificate, emphasizing the importance of testing the print layout',
|
|
||||||
},
|
|
||||||
signatoriesSectionTitle: {
|
|
||||||
id: 'course-authoring.certificates.signatories.section.title',
|
|
||||||
defaultMessage: 'Certificate signatories',
|
|
||||||
description: 'Title for the section',
|
|
||||||
},
|
|
||||||
addSignatoryButton: {
|
|
||||||
id: 'course-authoring.certificates.signatories.add.signatory.button',
|
|
||||||
defaultMessage: 'Add additional signatory',
|
|
||||||
description: 'Button text for adding a new signatory to the certificate',
|
|
||||||
},
|
|
||||||
addSignatoryButtonDescription: {
|
|
||||||
id: 'course-authoring.certificates.signatories.add.signatory.button.description',
|
|
||||||
defaultMessage: '(Add signatories for a certificate)',
|
|
||||||
description: 'Helper text for the button used to add signatories',
|
|
||||||
},
|
|
||||||
nameLabel: {
|
|
||||||
id: 'course-authoring.certificates.signatories.name.label',
|
|
||||||
defaultMessage: 'Name',
|
|
||||||
description: 'Label for the input field where the signatory name is entered',
|
|
||||||
},
|
|
||||||
namePlaceholder: {
|
|
||||||
id: 'course-authoring.certificates.signatories.name.placeholder',
|
|
||||||
defaultMessage: 'Name of the signatory',
|
|
||||||
description: 'Placeholder text for the signatory name input field',
|
|
||||||
},
|
|
||||||
nameDescription: {
|
|
||||||
id: 'course-authoring.certificates.signatories.name.description',
|
|
||||||
defaultMessage: 'The name of this signatory as it should appear on certificates.',
|
|
||||||
description: 'Helper text under the name input field',
|
|
||||||
},
|
|
||||||
titleLabel: {
|
|
||||||
id: 'course-authoring.certificates.signatories.title.label',
|
|
||||||
defaultMessage: 'Title',
|
|
||||||
description: 'Label for the input field where the signatory title is entered',
|
|
||||||
},
|
|
||||||
titlePlaceholder: {
|
|
||||||
id: 'course-authoring.certificates.signatories.title.placeholder',
|
|
||||||
defaultMessage: 'Title of the signatory',
|
|
||||||
description: 'Placeholder text for the signatory title input field',
|
|
||||||
},
|
|
||||||
titleDescription: {
|
|
||||||
id: 'course-authoring.certificates.signatories.title.description',
|
|
||||||
defaultMessage: 'Titles more than 100 characters may prevent students from printing their certificate on a single page.',
|
|
||||||
description: 'Helper text under the title input field',
|
|
||||||
},
|
|
||||||
organizationLabel: {
|
|
||||||
id: 'course-authoring.certificates.signatories.organization.label',
|
|
||||||
defaultMessage: 'Organization',
|
|
||||||
description: 'Label for the input field where the signatory organization is entered',
|
|
||||||
},
|
|
||||||
organizationPlaceholder: {
|
|
||||||
id: 'course-authoring.certificates.signatories.organization.placeholder',
|
|
||||||
defaultMessage: 'Organization of the signatory',
|
|
||||||
description: 'Placeholder text for the signatory organization input field',
|
|
||||||
},
|
|
||||||
organizationDescription: {
|
|
||||||
id: 'course-authoring.certificates.signatories.organization.description',
|
|
||||||
defaultMessage: 'The organization that this signatory belongs to, as it should appear on certificates.',
|
|
||||||
description: 'Helper text under the organization input field',
|
|
||||||
},
|
|
||||||
imageLabel: {
|
|
||||||
id: 'course-authoring.certificates.signatories.image.label',
|
|
||||||
defaultMessage: 'Signature image',
|
|
||||||
description: 'Label for the input field where the signatory image is selected',
|
|
||||||
},
|
|
||||||
imagePlaceholder: {
|
|
||||||
id: 'course-authoring.certificates.signatories.image.placeholder',
|
|
||||||
defaultMessage: 'Path to signature image',
|
|
||||||
description: 'Placeholder text for the signatory image input field',
|
|
||||||
},
|
|
||||||
imageDescription: {
|
|
||||||
id: 'course-authoring.certificates.signatories.image.description',
|
|
||||||
defaultMessage: 'Image must be in PNG format',
|
|
||||||
description: 'Helper text under the image input field',
|
|
||||||
},
|
|
||||||
uploadImageButton: {
|
|
||||||
id: 'course-authoring.certificates.signatories.upload.image.button',
|
|
||||||
defaultMessage: '{uploadText} signature image',
|
|
||||||
description: 'Button text for adding or replacing a signature image',
|
|
||||||
},
|
|
||||||
uploadModal: {
|
|
||||||
id: 'course-authoring.certificates.signatories.upload.modal',
|
|
||||||
defaultMessage: 'Upload',
|
|
||||||
description: 'Option for button text for adding a new signature image',
|
|
||||||
},
|
|
||||||
uploadModalReplace: {
|
|
||||||
id: 'course-authoring.certificates.signatories.upload.modal.replace',
|
|
||||||
defaultMessage: 'Replace',
|
|
||||||
description: 'Option for button text for replacing an existing signature image',
|
|
||||||
},
|
|
||||||
deleteSignatoryConfirmation: {
|
|
||||||
id: 'course-authoring.certificates.signatories.confirm-modal',
|
|
||||||
defaultMessage: 'Delete "{name}" from the list of signatories?',
|
|
||||||
description: 'Title for the confirmation modal when a user attempts to delete a signatory, where "{name}" is the name of the signatory to be deleted',
|
|
||||||
},
|
|
||||||
deleteSignatoryConfirmationMessage: {
|
|
||||||
id: 'course-authoring.certificates.signatories.confirm-modal.message',
|
|
||||||
defaultMessage: 'This action cannot be undone.',
|
|
||||||
description: 'A warning message that emphasizes the permanence of the delete action for a signatory',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default messages;
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import {
|
|
||||||
Image, Icon, Stack, IconButtonWithTooltip,
|
|
||||||
} from '@openedx/paragon';
|
|
||||||
import {
|
|
||||||
EditOutline as EditOutlineIcon,
|
|
||||||
} from '@openedx/paragon/icons';
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
|
|
||||||
import commonMessages from '../../messages';
|
|
||||||
import messages from '../messages';
|
|
||||||
|
|
||||||
const Signatory = ({
|
|
||||||
index,
|
|
||||||
name,
|
|
||||||
title,
|
|
||||||
organization,
|
|
||||||
signatureImagePath,
|
|
||||||
handleEdit,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-light-200 p-2.5 signatory" data-testid="signatory">
|
|
||||||
<Stack className="signatory__header" gap={3}>
|
|
||||||
<h3 className="section-title m-0">{`${intl.formatMessage(messages.signatoryTitle)} ${index + 1}`}</h3>
|
|
||||||
<Stack className="signatory__text-fields-stack">
|
|
||||||
<p className="signatory__text"><b>{intl.formatMessage(messages.nameLabel)}</b> {name}</p>
|
|
||||||
<p className="signatory__text"><b>{intl.formatMessage(messages.titleLabel)}</b> {title}</p>
|
|
||||||
<p className="signatory__text"><b>{intl.formatMessage(messages.organizationLabel)}</b> {organization}</p>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<IconButtonWithTooltip
|
|
||||||
className="signatory__action-button"
|
|
||||||
src={EditOutlineIcon}
|
|
||||||
iconAs={Icon}
|
|
||||||
alt={intl.formatMessage(commonMessages.editTooltip)}
|
|
||||||
tooltipContent={<div>{intl.formatMessage(commonMessages.editTooltip)}</div>}
|
|
||||||
onClick={handleEdit}
|
|
||||||
/>
|
|
||||||
<div className="signatory__image-container">
|
|
||||||
{signatureImagePath && (
|
|
||||||
<Image
|
|
||||||
src={`${getConfig().STUDIO_BASE_URL}${signatureImagePath}`}
|
|
||||||
fluid
|
|
||||||
alt={intl.formatMessage(messages.imageLabel)}
|
|
||||||
className="signatory__image"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Signatory.propTypes = {
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
organization: PropTypes.string.isRequired,
|
|
||||||
signatureImagePath: PropTypes.string.isRequired,
|
|
||||||
index: PropTypes.number.isRequired,
|
|
||||||
handleEdit: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Signatory;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { render } from '@testing-library/react';
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
|
|
||||||
import { signatoriesMock } from '../../__mocks__';
|
|
||||||
import commonMessages from '../../messages';
|
|
||||||
import messages from '../messages';
|
|
||||||
import Signatory from './Signatory';
|
|
||||||
|
|
||||||
const mockHandleEdit = jest.fn();
|
|
||||||
|
|
||||||
const renderSignatory = (props) => render(
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<Signatory {...props} />
|
|
||||||
</IntlProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultProps = { ...signatoriesMock[0], handleEdit: mockHandleEdit, index: 0 };
|
|
||||||
|
|
||||||
describe('Signatory Component', () => {
|
|
||||||
it('renders in MODE_STATES.view mode', () => {
|
|
||||||
const {
|
|
||||||
getByText, queryByText, getByAltText, getByRole,
|
|
||||||
} = renderSignatory(defaultProps);
|
|
||||||
const signatureImage = getByAltText(messages.imageLabel.defaultMessage);
|
|
||||||
const sectionTitle = getByRole('heading', { level: 3, name: `${messages.signatoryTitle.defaultMessage} ${defaultProps.index + 1}` });
|
|
||||||
|
|
||||||
expect(sectionTitle).toBeInTheDocument();
|
|
||||||
expect(getByText(defaultProps.name)).toBeInTheDocument();
|
|
||||||
expect(getByText(defaultProps.title)).toBeInTheDocument();
|
|
||||||
expect(getByText(defaultProps.organization)).toBeInTheDocument();
|
|
||||||
expect(signatureImage).toBeInTheDocument();
|
|
||||||
expect(signatureImage).toHaveAttribute('src', expect.stringContaining(defaultProps.signatureImagePath));
|
|
||||||
expect(queryByText(messages.namePlaceholder.defaultMessage)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls handleEdit when the edit button is clicked', () => {
|
|
||||||
const { getByRole } = renderSignatory(defaultProps);
|
|
||||||
|
|
||||||
const editButton = getByRole('button', { name: commonMessages.editTooltip.defaultMessage });
|
|
||||||
userEvent.click(editButton);
|
|
||||||
|
|
||||||
expect(mockHandleEdit).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
import {
|
|
||||||
Image, Icon, Stack, IconButtonWithTooltip, FormLabel, Form, Button, useToggle,
|
|
||||||
} from '@openedx/paragon';
|
|
||||||
import { DeleteOutline as DeleteOutlineIcon } from '@openedx/paragon/icons';
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
|
|
||||||
import ModalDropzone from '../../../generic/modal-dropzone/ModalDropzone';
|
|
||||||
import ModalNotification from '../../../generic/modal-notification';
|
|
||||||
import { updateSavingImageStatus } from '../../data/slice';
|
|
||||||
import commonMessages from '../../messages';
|
|
||||||
import messages from '../messages';
|
|
||||||
|
|
||||||
const SignatoryForm = ({
|
|
||||||
index,
|
|
||||||
name,
|
|
||||||
title,
|
|
||||||
isEdit,
|
|
||||||
handleBlur,
|
|
||||||
organization,
|
|
||||||
handleChange,
|
|
||||||
setFieldValue,
|
|
||||||
showDeleteButton,
|
|
||||||
signatureImagePath,
|
|
||||||
handleDeleteSignatory,
|
|
||||||
handleCancelUpdateSignatory,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const [isOpen, open, close] = useToggle(false);
|
|
||||||
const [isConfirmOpen, confirmOpen, confirmClose] = useToggle(false);
|
|
||||||
|
|
||||||
const handleImageUpload = (newImagePath) => {
|
|
||||||
setFieldValue(`signatories[${index}].signatureImagePath`, newImagePath);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSavingStatusDispatch = (status) => {
|
|
||||||
dispatch(updateSavingImageStatus(status));
|
|
||||||
};
|
|
||||||
|
|
||||||
const formData = [
|
|
||||||
{
|
|
||||||
labelText: intl.formatMessage(messages.nameLabel),
|
|
||||||
value: name,
|
|
||||||
name: `signatories[${index}].name`,
|
|
||||||
placeholder: intl.formatMessage(messages.namePlaceholder),
|
|
||||||
feedback: intl.formatMessage(messages.nameDescription),
|
|
||||||
onChange: handleChange,
|
|
||||||
onBlur: handleBlur,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
as: 'textarea',
|
|
||||||
labelText: intl.formatMessage(messages.titleLabel),
|
|
||||||
value: title,
|
|
||||||
name: `signatories[${index}].title`,
|
|
||||||
placeholder: intl.formatMessage(messages.titlePlaceholder),
|
|
||||||
feedback: intl.formatMessage(messages.titleDescription),
|
|
||||||
onChange: handleChange,
|
|
||||||
onBlur: handleBlur,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
labelText: intl.formatMessage(messages.organizationLabel),
|
|
||||||
value: organization,
|
|
||||||
name: `signatories[${index}].organization`,
|
|
||||||
placeholder: intl.formatMessage(messages.organizationPlaceholder),
|
|
||||||
feedback: intl.formatMessage(messages.organizationDescription),
|
|
||||||
onChange: handleChange,
|
|
||||||
onBlur: handleBlur,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const uploadReplaceText = intl.formatMessage(
|
|
||||||
messages.uploadImageButton,
|
|
||||||
{
|
|
||||||
uploadText: signatureImagePath
|
|
||||||
? intl.formatMessage(messages.uploadModalReplace)
|
|
||||||
: intl.formatMessage(messages.uploadModal),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-light-200 p-2.5 signatory-form" data-testid="signatory-form">
|
|
||||||
<Stack className="justify-content-between mb-4" direction="horizontal">
|
|
||||||
<h3 className="section-title">{`${intl.formatMessage(messages.signatoryTitle)} ${index + 1}`}</h3>
|
|
||||||
<Stack direction="horizontal" gap="2">
|
|
||||||
{showDeleteButton && (
|
|
||||||
<IconButtonWithTooltip
|
|
||||||
src={DeleteOutlineIcon}
|
|
||||||
iconAs={Icon}
|
|
||||||
alt={intl.formatMessage(commonMessages.deleteTooltip)}
|
|
||||||
tooltipContent={<div>{intl.formatMessage(commonMessages.deleteTooltip)}</div>}
|
|
||||||
onClick={confirmOpen}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap="4">
|
|
||||||
{formData.map(({ labelText, feedback, ...rest }) => (
|
|
||||||
<Form.Group className="m-0" key={labelText}>
|
|
||||||
<FormLabel>{labelText}</FormLabel>
|
|
||||||
<Form.Control {...rest} className="m-0" />
|
|
||||||
<Form.Control.Feedback>
|
|
||||||
<span className="x-small">{feedback}</span>
|
|
||||||
</Form.Control.Feedback>
|
|
||||||
</Form.Group>
|
|
||||||
))}
|
|
||||||
<Form.Group className="m-0">
|
|
||||||
<FormLabel> {intl.formatMessage(messages.imageLabel)}</FormLabel>
|
|
||||||
{signatureImagePath && (
|
|
||||||
<Image
|
|
||||||
src={`${getConfig().STUDIO_BASE_URL}${signatureImagePath}`}
|
|
||||||
fluid
|
|
||||||
alt={intl.formatMessage(messages.imageLabel)}
|
|
||||||
className="signatory__image"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Stack direction="horizontal" className="align-items-baseline">
|
|
||||||
<Stack>
|
|
||||||
<Form.Control
|
|
||||||
readOnly
|
|
||||||
value={signatureImagePath}
|
|
||||||
name={`signatories[${index}].signatureImagePath`}
|
|
||||||
placeholder={intl.formatMessage(messages.imagePlaceholder)}
|
|
||||||
/>
|
|
||||||
<Form.Control.Feedback>
|
|
||||||
<span className="x-small">{intl.formatMessage(messages.imageDescription)}</span>
|
|
||||||
</Form.Control.Feedback>
|
|
||||||
</Stack>
|
|
||||||
<Button onClick={open}>{uploadReplaceText}</Button>
|
|
||||||
</Stack>
|
|
||||||
</Form.Group>
|
|
||||||
</Stack>
|
|
||||||
{isEdit && (
|
|
||||||
<Stack direction="horizontal" gap="2" className="mt-4">
|
|
||||||
<Button type="submit">
|
|
||||||
{intl.formatMessage(commonMessages.saveTooltip)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline-primary"
|
|
||||||
onClick={() => handleCancelUpdateSignatory()}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(commonMessages.cardCancel)}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ModalDropzone
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={close}
|
|
||||||
onCancel={close}
|
|
||||||
onChange={handleImageUpload}
|
|
||||||
fileTypes={['png']}
|
|
||||||
onSavingStatus={handleSavingStatusDispatch}
|
|
||||||
imageHelpText={intl.formatMessage(messages.imageDescription)}
|
|
||||||
modalTitle={uploadReplaceText}
|
|
||||||
/>
|
|
||||||
<ModalNotification
|
|
||||||
isOpen={isConfirmOpen}
|
|
||||||
title={intl.formatMessage(messages.deleteSignatoryConfirmation, { name })}
|
|
||||||
message={intl.formatMessage(messages.deleteSignatoryConfirmationMessage)}
|
|
||||||
actionButtonText={intl.formatMessage(commonMessages.deleteTooltip)}
|
|
||||||
cancelButtonText={intl.formatMessage(commonMessages.cardCancel)}
|
|
||||||
handleCancel={confirmClose}
|
|
||||||
handleAction={() => {
|
|
||||||
confirmClose();
|
|
||||||
handleDeleteSignatory();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
SignatoryForm.defaultProps = {
|
|
||||||
isEdit: false,
|
|
||||||
handleChange: null,
|
|
||||||
handleBlur: null,
|
|
||||||
handleDeleteSignatory: null,
|
|
||||||
setFieldValue: null,
|
|
||||||
handleCancelUpdateSignatory: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
SignatoryForm.propTypes = {
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
organization: PropTypes.string.isRequired,
|
|
||||||
showDeleteButton: PropTypes.bool.isRequired,
|
|
||||||
signatureImagePath: PropTypes.string.isRequired,
|
|
||||||
index: PropTypes.number.isRequired,
|
|
||||||
isEdit: PropTypes.bool,
|
|
||||||
handleChange: PropTypes.func,
|
|
||||||
handleBlur: PropTypes.func,
|
|
||||||
setFieldValue: PropTypes.func,
|
|
||||||
handleDeleteSignatory: PropTypes.func,
|
|
||||||
handleCancelUpdateSignatory: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SignatoryForm;
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { render, waitFor } from '@testing-library/react';
|
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
|
|
||||||
import initializeStore from '../../../store';
|
|
||||||
import { signatoriesMock } from '../../__mocks__';
|
|
||||||
import commonMessages from '../../messages';
|
|
||||||
import messages from '../messages';
|
|
||||||
import SignatoryForm from './SignatoryForm';
|
|
||||||
|
|
||||||
let store;
|
|
||||||
|
|
||||||
const renderSignatory = (props) => render(
|
|
||||||
<Provider store={store}>
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<SignatoryForm {...props} />
|
|
||||||
</IntlProvider>,
|
|
||||||
</Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
certificates: {
|
|
||||||
certificatesData: {
|
|
||||||
certificates: [],
|
|
||||||
hasCertificateModes: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
...signatoriesMock[0],
|
|
||||||
showDeleteButton: true,
|
|
||||||
isEdit: true,
|
|
||||||
handleChange: jest.fn(),
|
|
||||||
handleBlur: jest.fn(),
|
|
||||||
setFieldValue: jest.fn(),
|
|
||||||
handleDeleteSignatory: jest.fn(),
|
|
||||||
handleCancelUpdateSignatory: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Signatory Component', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore(initialState);
|
|
||||||
});
|
|
||||||
it('renders in CREATE mode', () => {
|
|
||||||
const { queryByTestId, getByPlaceholderText } = renderSignatory(defaultProps);
|
|
||||||
|
|
||||||
expect(queryByTestId('signatory-view')).not.toBeInTheDocument();
|
|
||||||
expect(getByPlaceholderText(messages.namePlaceholder.defaultMessage)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles input change', async () => {
|
|
||||||
const handleChange = jest.fn();
|
|
||||||
const { getByPlaceholderText } = renderSignatory({ ...defaultProps, handleChange });
|
|
||||||
const input = getByPlaceholderText(messages.namePlaceholder.defaultMessage);
|
|
||||||
const newInputValue = 'Jane Doe';
|
|
||||||
|
|
||||||
userEvent.type(input, newInputValue, { name: 'signatories[0].name' });
|
|
||||||
|
|
||||||
waitFor(() => {
|
|
||||||
expect(handleChange).toHaveBeenCalledWith(expect.anything());
|
|
||||||
expect(input.value).toBe(newInputValue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opens image upload modal on button click', () => {
|
|
||||||
const { getByRole, queryByRole } = renderSignatory(defaultProps);
|
|
||||||
const replaceButton = getByRole(
|
|
||||||
'button',
|
|
||||||
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(queryByRole('presentation')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
userEvent.click(replaceButton);
|
|
||||||
|
|
||||||
expect(getByRole('presentation')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows confirm modal on delete icon click', async () => {
|
|
||||||
const { getByLabelText, getByText } = renderSignatory(defaultProps);
|
|
||||||
const deleteIcon = getByLabelText(commonMessages.deleteTooltip.defaultMessage);
|
|
||||||
|
|
||||||
userEvent.click(deleteIcon);
|
|
||||||
|
|
||||||
expect(getByText(messages.deleteSignatoryConfirmationMessage.defaultMessage)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cancels deletion of a signatory', () => {
|
|
||||||
const { getByRole } = renderSignatory(defaultProps);
|
|
||||||
|
|
||||||
const deleteIcon = getByRole('button', { name: commonMessages.deleteTooltip.defaultMessage });
|
|
||||||
userEvent.click(deleteIcon);
|
|
||||||
|
|
||||||
const cancelButton = getByRole('button', { name: commonMessages.cardCancel.defaultMessage });
|
|
||||||
userEvent.click(cancelButton);
|
|
||||||
|
|
||||||
expect(defaultProps.handleDeleteSignatory).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders without save button with isEdit=false', () => {
|
|
||||||
const { queryByRole } = renderSignatory({ ...defaultProps, isEdit: false });
|
|
||||||
|
|
||||||
const deleteIcon = queryByRole('button', { name: commonMessages.saveTooltip.defaultMessage });
|
|
||||||
|
|
||||||
expect(deleteIcon).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders button with Replace text if there is a signatureImagePath', () => {
|
|
||||||
const newProps = {
|
|
||||||
...defaultProps,
|
|
||||||
isEdit: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { getByRole, queryByRole } = renderSignatory(newProps);
|
|
||||||
|
|
||||||
const replaceButton = getByRole(
|
|
||||||
'button',
|
|
||||||
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
|
|
||||||
);
|
|
||||||
const uploadButton = queryByRole(
|
|
||||||
'button',
|
|
||||||
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModal.defaultMessage) },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(replaceButton).toBeInTheDocument();
|
|
||||||
expect(uploadButton).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders button with Upload text if there is no signatureImagePath', () => {
|
|
||||||
const newProps = {
|
|
||||||
...defaultProps,
|
|
||||||
signatureImagePath: '',
|
|
||||||
isEdit: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { getByRole, queryByRole } = renderSignatory(newProps);
|
|
||||||
|
|
||||||
const uploadButton = getByRole(
|
|
||||||
'button',
|
|
||||||
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModal.defaultMessage) },
|
|
||||||
);
|
|
||||||
const replaceButton = queryByRole(
|
|
||||||
'button',
|
|
||||||
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(uploadButton).toBeInTheDocument();
|
|
||||||
expect(replaceButton).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Card } from '@openedx/paragon';
|
|
||||||
|
|
||||||
import messages from '../messages';
|
|
||||||
|
|
||||||
const CertificateWithoutModes = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<Card.Section className="d-flex justify-content-center">
|
|
||||||
<span className="small">{intl.formatMessage(messages.withoutModesText)}</span>
|
|
||||||
</Card.Section>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CertificateWithoutModes;
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { render, waitFor } from '@testing-library/react';
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
|
||||||
|
|
||||||
import initializeStore from '../../store';
|
|
||||||
import messages from '../messages';
|
|
||||||
import WithoutModes from './CertificateWithoutModes';
|
|
||||||
|
|
||||||
const courseId = 'course-123';
|
|
||||||
let store;
|
|
||||||
|
|
||||||
const renderComponent = (props) => render(
|
|
||||||
<AppProvider store={store} messages={{}}>
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<WithoutModes courseId={courseId} {...props} />
|
|
||||||
</IntlProvider>
|
|
||||||
</AppProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('CertificateWithoutModes', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders correctly', async () => {
|
|
||||||
const { getByText, queryByText } = renderComponent();
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(getByText(messages.withoutModesText.defaultMessage)).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(queryByText(messages.headingActionsPreview.defaultMessage)).not.toBeInTheDocument();
|
|
||||||
expect(queryByText(messages.headingActionsDeactivate.defaultMessage)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Card, Stack } from '@openedx/paragon';
|
|
||||||
import { Formik, Form, FieldArray } from 'formik';
|
|
||||||
|
|
||||||
import CertificateDetails from '../certificate-details/CertificateDetails';
|
|
||||||
import CertificateSignatories from '../certificate-signatories/CertificateSignatories';
|
|
||||||
import useCertificatesList from './hooks/useCertificatesList';
|
|
||||||
|
|
||||||
const CertificatesList = ({ courseId }) => {
|
|
||||||
const {
|
|
||||||
editModes,
|
|
||||||
courseTitle,
|
|
||||||
certificates,
|
|
||||||
courseNumber,
|
|
||||||
initialValues,
|
|
||||||
courseNumberOverride,
|
|
||||||
setEditModes,
|
|
||||||
handleSubmit,
|
|
||||||
} = useCertificatesList(courseId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{certificates.map((certificate, idx) => (
|
|
||||||
<Formik initialValues={initialValues[idx]} onSubmit={handleSubmit} key={certificate.id}>
|
|
||||||
{({
|
|
||||||
values, handleChange, handleBlur, setFieldValue,
|
|
||||||
}) => (
|
|
||||||
<Form className="certificates-card-form" data-testid="certificates-list">
|
|
||||||
<Card>
|
|
||||||
<Card.Section>
|
|
||||||
<Stack gap="2">
|
|
||||||
<CertificateDetails
|
|
||||||
detailsCourseTitle={courseTitle}
|
|
||||||
detailsCourseNumber={courseNumber}
|
|
||||||
courseNumberOverride={courseNumberOverride}
|
|
||||||
courseTitleOverride={certificate.courseTitle}
|
|
||||||
certificateId={certificate.id}
|
|
||||||
/>
|
|
||||||
<FieldArray
|
|
||||||
name="signatories"
|
|
||||||
render={arrayHelpers => (
|
|
||||||
<CertificateSignatories
|
|
||||||
signatories={values.signatories}
|
|
||||||
arrayHelpers={arrayHelpers}
|
|
||||||
editModes={editModes}
|
|
||||||
initialSignatoriesValues={initialValues[idx].signatories}
|
|
||||||
handleChange={handleChange}
|
|
||||||
handleBlur={handleBlur}
|
|
||||||
setFieldValue={setFieldValue}
|
|
||||||
setEditModes={setEditModes}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Card.Section>
|
|
||||||
</Card>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CertificatesList.propTypes = {
|
|
||||||
courseId: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CertificatesList;
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { Provider } from 'react-redux';
|
|
||||||
import { render, waitFor, within } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { initializeMockApp } from '@edx/frontend-platform';
|
|
||||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
|
|
||||||
import { executeThunk } from '../../utils';
|
|
||||||
import initializeStore from '../../store';
|
|
||||||
import { MODE_STATES } from '../data/constants';
|
|
||||||
import { getCertificatesApiUrl, getUpdateCertificateApiUrl } from '../data/api';
|
|
||||||
import { fetchCertificates, updateCourseCertificate } from '../data/thunks';
|
|
||||||
import { certificatesMock, certificatesDataMock } from '../__mocks__';
|
|
||||||
import signatoryMessages from '../certificate-signatories/messages';
|
|
||||||
import messages from '../messages';
|
|
||||||
import CertificatesList from './CertificatesList';
|
|
||||||
|
|
||||||
let axiosMock;
|
|
||||||
let store;
|
|
||||||
const courseId = 'course-123';
|
|
||||||
|
|
||||||
const renderComponent = () => render(
|
|
||||||
<Provider store={store}>
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<CertificatesList courseId="course-123" />
|
|
||||||
</IntlProvider>
|
|
||||||
</Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('CertificatesList Component', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
initializeMockApp({
|
|
||||||
authenticatedUser: {
|
|
||||||
userId: 3,
|
|
||||||
username: 'abc123',
|
|
||||||
administrator: true,
|
|
||||||
roles: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
store = initializeStore();
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
|
||||||
axiosMock
|
|
||||||
.onGet(getCertificatesApiUrl(courseId))
|
|
||||||
.reply(200, {
|
|
||||||
...certificatesDataMock,
|
|
||||||
certificates: certificatesMock,
|
|
||||||
});
|
|
||||||
await executeThunk(fetchCertificates(courseId), store.dispatch);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders each certificate', () => {
|
|
||||||
const { getByText } = renderComponent();
|
|
||||||
|
|
||||||
certificatesMock.forEach((certificate) => {
|
|
||||||
certificate.signatories.forEach((signatory) => {
|
|
||||||
expect(getByText(signatory.name)).toBeInTheDocument();
|
|
||||||
expect(getByText(signatory.title)).toBeInTheDocument();
|
|
||||||
expect(getByText(signatory.organization)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('update certificate', async () => {
|
|
||||||
const {
|
|
||||||
getByText, queryByText, getByPlaceholderText, getByRole, getAllByLabelText,
|
|
||||||
} = renderComponent();
|
|
||||||
|
|
||||||
const signatoryNameValue = 'Updated signatory name';
|
|
||||||
const newCertificateData = {
|
|
||||||
...certificatesDataMock,
|
|
||||||
certificates: [{
|
|
||||||
...certificatesMock[0],
|
|
||||||
signatories: [{
|
|
||||||
...certificatesMock[0].signatories[0],
|
|
||||||
name: signatoryNameValue,
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
const editButtons = getAllByLabelText(messages.editTooltip.defaultMessage);
|
|
||||||
|
|
||||||
userEvent.click(editButtons[1]);
|
|
||||||
|
|
||||||
const nameInput = getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage);
|
|
||||||
userEvent.clear(nameInput);
|
|
||||||
userEvent.type(nameInput, signatoryNameValue);
|
|
||||||
|
|
||||||
userEvent.click(getByRole('button', { name: messages.saveTooltip.defaultMessage }));
|
|
||||||
|
|
||||||
axiosMock
|
|
||||||
.onPost(getUpdateCertificateApiUrl(courseId, certificatesMock.id))
|
|
||||||
.reply(200, newCertificateData);
|
|
||||||
await executeThunk(updateCourseCertificate(courseId, newCertificateData), store.dispatch);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(getByText(newCertificateData.certificates[0].signatories[0].name)).toBeInTheDocument();
|
|
||||||
expect(queryByText(certificatesDataMock.certificates[0].signatories[0].name)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('toggle edit signatory', async () => {
|
|
||||||
const {
|
|
||||||
getAllByLabelText, queryByPlaceholderText, getByTestId, getByPlaceholderText,
|
|
||||||
} = renderComponent();
|
|
||||||
const editButtons = getAllByLabelText(messages.editTooltip.defaultMessage);
|
|
||||||
|
|
||||||
expect(editButtons.length).toBe(3);
|
|
||||||
|
|
||||||
userEvent.click(editButtons[1]);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(getByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
userEvent.click(within(getByTestId('signatory-form')).getByRole('button', { name: messages.cardCancel.defaultMessage }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(queryByPlaceholderText(signatoryMessages.namePlaceholder.defaultMessage)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('toggle certificate edit all', async () => {
|
|
||||||
const { getByTestId } = renderComponent();
|
|
||||||
const detailsSection = getByTestId('certificate-details');
|
|
||||||
const editButton = within(detailsSection).getByLabelText(messages.editTooltip.defaultMessage);
|
|
||||||
userEvent.click(editButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(store.getState().certificates.componentMode).toBe(MODE_STATES.editAll);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
|
||||||
|
|
||||||
import { MODE_STATES } from '../../data/constants';
|
|
||||||
import {
|
|
||||||
getCourseTitle, getCourseNumber, getCourseNumberOverride, getCertificates,
|
|
||||||
} from '../../data/selectors';
|
|
||||||
import { updateCourseCertificate } from '../../data/thunks';
|
|
||||||
import { setMode } from '../../data/slice';
|
|
||||||
import { defaultCertificate } from '../../constants';
|
|
||||||
|
|
||||||
const useCertificatesList = (courseId) => {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const certificates = useSelector(getCertificates);
|
|
||||||
const courseTitle = useSelector(getCourseTitle);
|
|
||||||
const courseNumber = useSelector(getCourseNumber);
|
|
||||||
const courseNumberOverride = useSelector(getCourseNumberOverride);
|
|
||||||
|
|
||||||
const [editModes, setEditModes] = useState({});
|
|
||||||
|
|
||||||
const initialValues = certificates.map((certificate) => ({
|
|
||||||
...certificate,
|
|
||||||
courseTitle: certificate.courseTitle || defaultCertificate.courseTitle,
|
|
||||||
signatories: certificate.signatories || defaultCertificate.signatories,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handleSubmit = async (values) => {
|
|
||||||
await dispatch(updateCourseCertificate(courseId, values));
|
|
||||||
setEditModes({});
|
|
||||||
dispatch(setMode(MODE_STATES.view));
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
editModes,
|
|
||||||
courseTitle,
|
|
||||||
certificates,
|
|
||||||
courseNumber,
|
|
||||||
initialValues,
|
|
||||||
courseNumberOverride,
|
|
||||||
setEditModes,
|
|
||||||
handleSubmit,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useCertificatesList;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
|
|
||||||
export const defaultCertificate = {
|
|
||||||
courseTitle: '',
|
|
||||||
signatories: [{
|
|
||||||
id: `local-${uuid()}`,
|
|
||||||
name: '',
|
|
||||||
title: '',
|
|
||||||
organization: '',
|
|
||||||
signatureImagePath: '',
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
|
|
||||||
import { prepareCertificatePayload } from '../utils';
|
|
||||||
|
|
||||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
|
||||||
|
|
||||||
export const getCertificatesApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/certificates/${courseId}`;
|
|
||||||
export const getCertificateApiUrl = (courseId) => `${getApiBaseUrl()}/certificates/${courseId}`;
|
|
||||||
export const getUpdateCertificateApiUrl = (courseId, certificateId) => `${getCertificateApiUrl(courseId)}/${certificateId}`;
|
|
||||||
export const getUpdateCertificateActiveStatusApiUrl = (path) => `${getApiBaseUrl()}${path}`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets certificates for a course.
|
|
||||||
* @param {string} courseId
|
|
||||||
* @returns {Promise<Object>}
|
|
||||||
*/
|
|
||||||
export async function getCertificates(courseId) {
|
|
||||||
const { data } = await getAuthenticatedHttpClient()
|
|
||||||
.get(getCertificatesApiUrl(courseId));
|
|
||||||
|
|
||||||
return camelCaseObject(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create course certificate.
|
|
||||||
* @param {string} courseId
|
|
||||||
* @param {object} certificatesData
|
|
||||||
* @returns {Promise<Object>}
|
|
||||||
*/
|
|
||||||
|
|
||||||
export async function createCertificate(courseId, certificatesData) {
|
|
||||||
const { data } = await getAuthenticatedHttpClient()
|
|
||||||
.post(
|
|
||||||
getCertificateApiUrl(courseId),
|
|
||||||
prepareCertificatePayload(certificatesData),
|
|
||||||
);
|
|
||||||
|
|
||||||
return camelCaseObject(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update course certificate.
|
|
||||||
* @param {string} courseId
|
|
||||||
* @param {object} certificateData
|
|
||||||
* @returns {Promise<Object>}
|
|
||||||
*/
|
|
||||||
export async function updateCertificate(courseId, certificateData) {
|
|
||||||
const { data } = await getAuthenticatedHttpClient()
|
|
||||||
.post(
|
|
||||||
getUpdateCertificateApiUrl(courseId, certificateData.id),
|
|
||||||
prepareCertificatePayload(certificateData),
|
|
||||||
);
|
|
||||||
return camelCaseObject(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete course certificate.
|
|
||||||
* @param {string} courseId
|
|
||||||
* @param {object} certificateId
|
|
||||||
* @returns {Promise<Object>}
|
|
||||||
*/
|
|
||||||
export async function deleteCertificate(courseId, certificateId) {
|
|
||||||
const { data } = await getAuthenticatedHttpClient()
|
|
||||||
.delete(
|
|
||||||
getUpdateCertificateApiUrl(courseId, certificateId),
|
|
||||||
);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Activate/deactivate course certificate.
|
|
||||||
* @param {string} courseId
|
|
||||||
* @param {object} activationStatus
|
|
||||||
* @returns {Promise<Object>}
|
|
||||||
*/
|
|
||||||
export async function updateActiveStatus(path, activationStatus) {
|
|
||||||
const body = {
|
|
||||||
is_active: activationStatus,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data } = await getAuthenticatedHttpClient()
|
|
||||||
.post(
|
|
||||||
getUpdateCertificateActiveStatusApiUrl(path),
|
|
||||||
body,
|
|
||||||
);
|
|
||||||
return camelCaseObject(data);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export const MODE_STATES = {
|
|
||||||
noModes: 'no_modes',
|
|
||||||
noCertificates: 'no_certificates',
|
|
||||||
view: 'view',
|
|
||||||
editAll: 'edit_all',
|
|
||||||
create: 'create',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ACTIVATION_MESSAGES = {
|
|
||||||
activating: 'Activating',
|
|
||||||
deactivating: 'Deactivating',
|
|
||||||
};
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { createSelector } from '@reduxjs/toolkit';
|
|
||||||
|
|
||||||
export const getLoadingStatus = (state) => state.certificates.loadingStatus;
|
|
||||||
export const getSavingStatus = (state) => state.certificates.savingStatus;
|
|
||||||
export const getSavingImageStatus = (state) => state.certificates.savingImageStatus;
|
|
||||||
export const getErrorMessage = (state) => state.certificates.errorMessage;
|
|
||||||
export const getSendRequestErrors = (state) => state.certificates.sendRequestErrors.developer_message;
|
|
||||||
export const getCertificates = state => state.certificates.certificatesData.certificates;
|
|
||||||
export const getHasCertificateModes = state => state.certificates.certificatesData.hasCertificateModes;
|
|
||||||
export const getCourseModes = state => state.certificates.certificatesData.courseModes;
|
|
||||||
export const getCertificateActivationUrl = state => state.certificates.certificatesData.certificateActivationHandlerUrl;
|
|
||||||
export const getCertificateWebViewUrl = state => state.certificates.certificatesData.certificateWebViewUrl;
|
|
||||||
export const getIsCertificateActive = state => state.certificates.certificatesData.isActive;
|
|
||||||
export const getComponentMode = state => state.certificates.componentMode;
|
|
||||||
export const getCourseNumber = state => state.certificates.certificatesData.courseNumber;
|
|
||||||
export const getCourseNumberOverride = state => state.certificates.certificatesData.courseNumberOverride;
|
|
||||||
export const getCourseTitle = state => state.certificates.certificatesData.courseTitle;
|
|
||||||
|
|
||||||
export const getHasCertificates = createSelector(
|
|
||||||
[getCertificates],
|
|
||||||
(certificates) => certificates && certificates.length > 0,
|
|
||||||
);
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
/* eslint-disable no-param-reassign */
|
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
|
||||||
|
|
||||||
import { RequestStatus } from '../../data/constants';
|
|
||||||
import { MODE_STATES } from './constants';
|
|
||||||
|
|
||||||
const slice = createSlice({
|
|
||||||
name: 'certificates',
|
|
||||||
initialState: {
|
|
||||||
certificatesData: {},
|
|
||||||
componentMode: MODE_STATES.noModes,
|
|
||||||
loadingStatus: RequestStatus.PENDING,
|
|
||||||
savingStatus: '',
|
|
||||||
savingImageStatus: '',
|
|
||||||
errorMessage: '',
|
|
||||||
},
|
|
||||||
reducers: {
|
|
||||||
updateSavingStatus: (state, { payload }) => {
|
|
||||||
const { status, errorMessage } = payload;
|
|
||||||
state.savingStatus = status;
|
|
||||||
state.errorMessage = errorMessage;
|
|
||||||
},
|
|
||||||
updateSavingImageStatus: (state, { payload }) => {
|
|
||||||
state.savingImageStatus = payload.status;
|
|
||||||
},
|
|
||||||
updateLoadingStatus: (state, { payload }) => {
|
|
||||||
state.loadingStatus = payload.status;
|
|
||||||
},
|
|
||||||
fetchCertificatesSuccess: (state, { payload }) => {
|
|
||||||
Object.assign(state.certificatesData, payload);
|
|
||||||
},
|
|
||||||
createCertificateSuccess: (state, action) => {
|
|
||||||
state.certificatesData.certificates.push(action.payload);
|
|
||||||
},
|
|
||||||
updateCertificateSuccess: (state, action) => {
|
|
||||||
const index = state.certificatesData.certificates.findIndex(c => c.id === action.payload.id);
|
|
||||||
|
|
||||||
if (index !== -1) {
|
|
||||||
state.certificatesData.certificates[index] = action.payload;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setMode: (state, action) => {
|
|
||||||
state.componentMode = action.payload;
|
|
||||||
},
|
|
||||||
deleteCertificateSuccess: (state) => {
|
|
||||||
state.certificatesData.certificates = [];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const {
|
|
||||||
setMode,
|
|
||||||
updateSavingStatus,
|
|
||||||
updateLoadingStatus,
|
|
||||||
updateSavingImageStatus,
|
|
||||||
fetchCertificatesSuccess,
|
|
||||||
createCertificateSuccess,
|
|
||||||
updateCertificateSuccess,
|
|
||||||
deleteCertificateSuccess,
|
|
||||||
} = slice.actions;
|
|
||||||
|
|
||||||
export const { reducer } = slice;
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { RequestStatus } from '../../data/constants';
|
|
||||||
import {
|
|
||||||
hideProcessingNotification,
|
|
||||||
showProcessingNotification,
|
|
||||||
} from '../../generic/processing-notification/data/slice';
|
|
||||||
import { handleResponseErrors } from '../../generic/saving-error-alert';
|
|
||||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
|
||||||
import {
|
|
||||||
getCertificates,
|
|
||||||
createCertificate,
|
|
||||||
updateCertificate,
|
|
||||||
deleteCertificate,
|
|
||||||
updateActiveStatus,
|
|
||||||
} from './api';
|
|
||||||
import {
|
|
||||||
fetchCertificatesSuccess,
|
|
||||||
updateLoadingStatus,
|
|
||||||
updateSavingStatus,
|
|
||||||
createCertificateSuccess,
|
|
||||||
updateCertificateSuccess,
|
|
||||||
deleteCertificateSuccess,
|
|
||||||
} from './slice';
|
|
||||||
import { ACTIVATION_MESSAGES } from './constants';
|
|
||||||
|
|
||||||
export function fetchCertificates(courseId) {
|
|
||||||
return async (dispatch) => {
|
|
||||||
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const certificates = await getCertificates(courseId);
|
|
||||||
|
|
||||||
dispatch(fetchCertificatesSuccess(certificates));
|
|
||||||
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
|
||||||
} catch (error) {
|
|
||||||
if (error.response && error.response.status === 403) {
|
|
||||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED }));
|
|
||||||
} else {
|
|
||||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCourseCertificate(courseId, certificate) {
|
|
||||||
return async (dispatch) => {
|
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
|
||||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const certificateValues = await createCertificate(courseId, certificate);
|
|
||||||
dispatch(createCertificateSuccess(certificateValues));
|
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
return handleResponseErrors(error, dispatch, updateSavingStatus);
|
|
||||||
} finally {
|
|
||||||
dispatch(hideProcessingNotification());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateCourseCertificate(courseId, certificate) {
|
|
||||||
return async (dispatch) => {
|
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
|
||||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const certificatesValues = await updateCertificate(courseId, certificate);
|
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
|
||||||
dispatch(updateCertificateSuccess(certificatesValues));
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
return handleResponseErrors(error, dispatch, updateSavingStatus);
|
|
||||||
} finally {
|
|
||||||
dispatch(hideProcessingNotification());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteCourseCertificate(courseId, certificateId) {
|
|
||||||
return async (dispatch) => {
|
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
|
||||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const certificatesValues = await deleteCertificate(courseId, certificateId);
|
|
||||||
dispatch(deleteCertificateSuccess(certificatesValues));
|
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
return handleResponseErrors(error, dispatch, updateSavingStatus);
|
|
||||||
} finally {
|
|
||||||
dispatch(hideProcessingNotification());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateCertificateActiveStatus(courseId, path, activationStatus) {
|
|
||||||
return async (dispatch) => {
|
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
|
||||||
|
|
||||||
dispatch(showProcessingNotification(
|
|
||||||
activationStatus ? ACTIVATION_MESSAGES.activating : ACTIVATION_MESSAGES.deactivating,
|
|
||||||
));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateActiveStatus(path, activationStatus);
|
|
||||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
|
||||||
dispatch(fetchCertificates(courseId));
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
return handleResponseErrors(error, dispatch, updateSavingStatus);
|
|
||||||
} finally {
|
|
||||||
dispatch(hideProcessingNotification());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user