Compare commits
7 Commits
test_hyper
...
CourseRole
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2bfb1fb7b | ||
|
|
c754a5e519 | ||
|
|
1e9146a5b9 | ||
|
|
a518fada29 | ||
|
|
69d9ea318e | ||
|
|
e74e1ff5aa | ||
|
|
1137dae97a |
13
.env
13
.env
@@ -1,4 +1,3 @@
|
||||
APP_ID='authoring'
|
||||
NODE_ENV='production'
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
BASE_URL=''
|
||||
@@ -31,19 +30,13 @@ USER_INFO_COOKIE_NAME=''
|
||||
ENABLE_ACCESSIBILITY_PAGE=false
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=false
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=false
|
||||
INVITE_STUDENTS_EMAIL_TO=''
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
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"
|
||||
AI_TRANSLATIONS_BASE_URL=''
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
APP_ID='authoring'
|
||||
NODE_ENV='development'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2001'
|
||||
@@ -33,19 +32,13 @@ USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_ACCESSIBILITY_PAGE=false
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=true
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=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"
|
||||
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'
|
||||
|
||||
10
.env.test
10
.env.test
@@ -1,4 +1,3 @@
|
||||
APP_ID='authoring'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2001'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
@@ -29,16 +28,9 @@ SUPPORT_URL='https://support.edx.org'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_UNIT_PAGE=true
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "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/*
|
||||
dist/
|
||||
node_modules/
|
||||
jest.config.js
|
||||
env.config.jsx
|
||||
example.env.config.jsx
|
||||
jest.config.js
|
||||
22
.eslintrc.js
22
.eslintrc.js
@@ -1,6 +1,5 @@
|
||||
const path = require('path');
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig(
|
||||
'eslint',
|
||||
@@ -11,25 +10,8 @@ module.exports = createConfig(
|
||||
}],
|
||||
'template-curly-spacing': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
indent: ['error', 2],
|
||||
'no-restricted-exports': 'off',
|
||||
// There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers
|
||||
'no-restricted-syntax': 'off',
|
||||
},
|
||||
settings: {
|
||||
// Import URLs should be resolved using aliases
|
||||
'import/resolver': {
|
||||
webpack: {
|
||||
config: path.resolve(__dirname, 'webpack.dev.config.js'),
|
||||
},
|
||||
},
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['plugins/**/*.test.jsx'],
|
||||
rules: {
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
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"
|
||||
27
.github/pull_request_template.md
vendored
27
.github/pull_request_template.md
vendored
@@ -1,27 +0,0 @@
|
||||
## Description
|
||||
|
||||
Describe what this pull request changes, and why. Include implications for people using this change.
|
||||
Design decisions and their rationales should be documented in the repo (docstring / ADR), per
|
||||
|
||||
Useful information to include:
|
||||
- Which edX user roles will this change impact? Common user roles are "Learner", "Course Author",
|
||||
"Developer", and "Operator".
|
||||
- Include screenshots for changes to the UI (ideally, both "before" and "after" screenshots, if applicable).
|
||||
- Provide links to the description of corresponding configuration changes. Remember to correctly annotate these
|
||||
changes.
|
||||
|
||||
## Supporting information
|
||||
|
||||
Link to other information about the change, such as Jira issues, GitHub issues, or Discourse discussions.
|
||||
Be sure to check they are publicly readable, or if not, repeat the information here.
|
||||
|
||||
## Testing instructions
|
||||
|
||||
Please provide detailed step-by-step instructions for testing this change.
|
||||
|
||||
|
||||
## Other information
|
||||
|
||||
Include anything else that will help reviewers and consumers understand the change.
|
||||
- Does this change depend on other changes elsewhere?
|
||||
- Any special concerns or limitations? For example: deprecations, migrations, security, or accessibility.
|
||||
26
.github/workflows/validate.yml
vendored
26
.github/workflows/validate.yml
vendored
@@ -9,29 +9,15 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
- run: make validate.ci
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: code-coverage-report
|
||||
path: coverage/*.*
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: code-coverage-report
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,7 +1,6 @@
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
.run
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
@@ -24,9 +23,3 @@ temp/babel-plugin-react-intl
|
||||
|
||||
# Local environment overrides
|
||||
.env.private
|
||||
|
||||
# Messages .json files fetched by atlas
|
||||
src/i18n/messages/
|
||||
|
||||
# environment js config
|
||||
env.config.jsx
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"scss/at-rule-no-unknown": true,
|
||||
"scss/at-import-partial-extension": null,
|
||||
"scss/comment-no-empty": null,
|
||||
"import-notation": "string",
|
||||
"property-no-unknown": [true, {
|
||||
"ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"]
|
||||
}],
|
||||
|
||||
9
.tx/config
Normal file
9
.tx/config
Normal file
@@ -0,0 +1,9 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[o:open-edx:p:edx-platform:r:frontend-app-course-authoring]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# The following users are the maintainers of all frontend-app-authoring files
|
||||
* @openedx/2u-tnl
|
||||
27
Makefile
27
Makefile
@@ -1,3 +1,7 @@
|
||||
transifex_resource = frontend-app-course-authoring
|
||||
export TRANSIFEX_RESOURCE = ${transifex_resource}
|
||||
transifex_langs = "ar,de,de_DE,es_419,fa_IR,fr,fr_CA,hi,it,it_IT,pt,pt_PT,ru,uk,zh_CN"
|
||||
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
@@ -29,18 +33,37 @@ detect_changed_source_translations:
|
||||
# Checking for changed translations...
|
||||
git diff --exit-code $(i18n)
|
||||
|
||||
# Pushes translations to Transifex. You must run make extract_translations first.
|
||||
push_translations:
|
||||
# Pushing strings to Transifex...
|
||||
tx push -s
|
||||
# Fetching hashes from Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
|
||||
# Writing out comments to file...
|
||||
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
ifeq ($(OPENEDX_ATLAS_PULL),)
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
|
||||
else
|
||||
# Pulls translations using atlas.
|
||||
pull_translations:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull $(ATLAS_OPTIONS) \
|
||||
translations/frontend-component-ai-translations/src/i18n/messages:frontend-component-ai-translations \
|
||||
translations/frontend-lib-content-components/src/i18n/messages:frontend-lib-content-components \
|
||||
translations/frontend-platform/src/i18n/messages:frontend-platform \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring
|
||||
|
||||
$(intl_imports) frontend-component-ai-translations frontend-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
|
||||
endif
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
@@ -53,7 +76,7 @@ validate:
|
||||
npm run i18n_extract
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run types
|
||||
npm run test:ci
|
||||
npm run test
|
||||
npm run build
|
||||
|
||||
.PHONY: validate.ci
|
||||
|
||||
132
README.rst
132
README.rst
@@ -1,5 +1,5 @@
|
||||
frontend-app-authoring
|
||||
######################
|
||||
frontend-app-course-authoring
|
||||
#############################
|
||||
|
||||
|license-badge| |status-badge| |codecov-badge|
|
||||
|
||||
@@ -7,9 +7,9 @@ frontend-app-authoring
|
||||
Purpose
|
||||
*******
|
||||
|
||||
This implements most of the frontend for **Open edX Studio**, allowing authors to create and edit courses, libraries, and their learning components.
|
||||
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
|
||||
|
||||
A few parts of Studio still default to the `"legacy" pages defined in edx-platform <https://github.com/openedx/edx-platform/tree/master/cms>`_, but those are rapidly being deprecated and replaced with the React- and Paragon-based pages defined here.
|
||||
Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below.
|
||||
|
||||
|
||||
Getting Started
|
||||
@@ -18,87 +18,51 @@ Getting Started
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
`Tutor`_ is currently recommended as a development environment for the Authoring
|
||||
MFE. Most likely, it already has this MFE configured; however, you'll need to
|
||||
make some changes in order to run it in development mode. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ for details, or follow the quick
|
||||
guide below.
|
||||
The `devstack`_ is currently recommended as a development environment for your
|
||||
new MFE. If you start it with ``make dev.up.lms`` that should give you
|
||||
everything you need as a companion to this frontend.
|
||||
|
||||
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ to get started using it.
|
||||
|
||||
.. _Devstack: https://github.com/openedx/devstack
|
||||
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
Cloning and Setup
|
||||
=================
|
||||
All features that integrate into the edx-platform CMS require that the ``COURSE_AUTHORING_MICROFRONTEND_URL`` Django setting is set in the CMS environment and points to this MFE's deployment URL. This should be done automatically if you are using devstack or tutor-mfe.
|
||||
|
||||
1. Clone your new repo:
|
||||
Cloning and Startup
|
||||
===================
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git clone https://github.com/openedx/frontend-app-authoring.git
|
||||
1. Clone the repo:
|
||||
|
||||
2. Use the version of Node specified in the ``.nvmrc`` file.
|
||||
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
|
||||
|
||||
The current version of the micro-frontend build scripts supports node 20.
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes an ``.nvmrc`` file to help in setting the
|
||||
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
|
||||
2. Use node v18.x.
|
||||
|
||||
3. Stop the Tutor devstack, if it's running: ``tutor dev stop``
|
||||
The current version of the micro-frontend build scripts support node 18.
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes an .nvmrc file to help in setting the
|
||||
correct node version via `nvm use`_.
|
||||
|
||||
4. Next, we need to tell Tutor that we're going to be running this repo in
|
||||
development mode, and it should be excluded from the ``mfe`` container that
|
||||
otherwise runs every MFE. Run this:
|
||||
3. Install npm dependencies:
|
||||
|
||||
.. code-block:: bash
|
||||
``cd frontend-app-course-authoring && npm install``
|
||||
|
||||
tutor mounts add /path/to/frontend-app-authoring
|
||||
|
||||
5. Start Tutor in development mode. This command will start the LMS and Studio,
|
||||
and other required MFEs like ``authn`` and ``account``, but will not start
|
||||
the Authoring MFE, which we're going to run on the host instead of in a
|
||||
container managed by Tutor. Run:
|
||||
4. Start the dev server:
|
||||
|
||||
.. code-block:: bash
|
||||
``npm start``
|
||||
|
||||
tutor dev start lms cms mfe
|
||||
|
||||
Startup
|
||||
=======
|
||||
|
||||
1. Install npm dependencies:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
cd frontend-app-authoring && npm ci
|
||||
|
||||
2. Start the dev server:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
npm run dev
|
||||
|
||||
Then you can access the app at http://apps.local.openedx.io:2001/course-authoring/home
|
||||
|
||||
Troubleshooting
|
||||
---------------
|
||||
|
||||
* If you see an "Invalid Host header" error, then you're probably using a different domain name for your devstack such as
|
||||
``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run
|
||||
these commands to update your devstack's domain names:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tutor dev stop
|
||||
tutor config save --set LMS_HOST=local.openedx.io --set CMS_HOST=studio.local.openedx.io
|
||||
tutor dev launch -I --skip-build
|
||||
tutor dev stop authoring # We will run this MFE on the host
|
||||
|
||||
* If tutor-mfe is not starting the authoring MFE in development mode (eg. `tutor dev start authoring` fails), it may be due to
|
||||
using a tutor version that expects the MFE name to be frontend-app-course-authoring (the previous name of this repo). To fix
|
||||
this, you can rename the cloned repo directory to frontend-app-course-authoring. More information can be found in
|
||||
`this forum post <https://discuss.openedx.org/t/repo-rename-frontend-app-course-authoring-frontend-app-authoring/13930/2>`__.
|
||||
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
|
||||
or whatever port you setup.
|
||||
|
||||
|
||||
Features
|
||||
@@ -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_problem_editor``: must be enabled for the new Problem Xblock editor to be used in Studio
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
In additional to the standard settings, the following local configuration item is required:
|
||||
|
||||
* ``ENABLE_NEW_EDITOR_PAGES``: must be enabled in order to actually present the new XBlock editors (on by default)
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
When a corresponding waffle flag is set, upon editing a block in Studio, the view is rendered by this MFE instead of by the XBlock's authoring view. The user remains in Studio.
|
||||
|
||||
.. note::
|
||||
|
||||
The new editors themselves are currently implemented in a repository outside ``openedx``: `frontend-lib-content-components <https://github.com/edx/frontend-lib-content-components/>`_, a dependency of this MFE. This repository is slated to be moved to the ``openedx`` org, however.
|
||||
|
||||
Feature: New Proctoring Exams View
|
||||
==================================
|
||||
|
||||
@@ -292,30 +267,13 @@ Configuration
|
||||
|
||||
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
|
||||
Tagging/Taxonomy functionality.
|
||||
* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled in order to actually present the new Tagging/Taxonomy pages.
|
||||
|
||||
|
||||
Feature: Libraries V2/Legacy Tabs
|
||||
=================================
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
In additional to the standard settings, the following local configurations can be set to switch between different library modes:
|
||||
|
||||
* ``MEILISEARCH_ENABLED``: Studio setting which is enabled when the `meilisearch plugin`_ is installed.
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.disable_legacy_libraries``: this feature flag must be OFF to show legacy Libraries V1
|
||||
* ``contentstore.new_studio_mfe.disable_new_libraries``: this feature flag must be OFF to show Content Libraries V2
|
||||
|
||||
.. _meilisearch plugin: https://github.com/open-craft/tutor-contrib-meilisearch
|
||||
|
||||
Developing
|
||||
**********
|
||||
|
||||
`Tutor <https://docs.tutor.edly.io/>`_ is the community-supported Open edX development environment. See the `tutor-mfe plugin README <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`_ for more information.
|
||||
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
|
||||
|
||||
|
||||
If your devstack includes the default Demo course, you can visit the following URLs to see content:
|
||||
@@ -344,8 +302,8 @@ The production build is created with ``npm run build``.
|
||||
:target: https://travis-ci.com/edx/frontend-app-course-authoring
|
||||
.. |Codecov| image:: https://codecov.io/gh/edx/frontend-app-course-authoring/branch/master/graph/badge.svg
|
||||
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-authoring.svg
|
||||
:target: @edx/frontend-app-authoring
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
|
||||
:target: @edx/frontend-app-course-authoring
|
||||
|
||||
Internationalization
|
||||
====================
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# This file records information about this repo. Its use is described in OEP-55:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
|
||||
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: 'frontend-app-authoring'
|
||||
description: "The frontend (MFE) for Open edX Authoring (aka Studio)"
|
||||
links:
|
||||
- url: "https://github.com/openedx/frontend-app-authoring"
|
||||
title: "Frontend app authoring"
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
openedx.org/release: "master"
|
||||
spec:
|
||||
owner: group:2u-tnl
|
||||
type: 'website'
|
||||
lifecycle: 'production'
|
||||
@@ -0,0 +1,21 @@
|
||||
Background
|
||||
==========
|
||||
|
||||
This is a summary of the technical decisions made for the Roles & Permissions
|
||||
project as we implemented the permissions check system in the ``frontend-app-course-authoring``.
|
||||
|
||||
The ``frontend-app-course-authoring`` was already created when the
|
||||
Permissions project started, so it already had a coding style, store
|
||||
management and its own best practices.
|
||||
We aligned to these requirements.
|
||||
|
||||
Frontend Architecture
|
||||
---------------------
|
||||
|
||||
* `Readme <https://github.com/openedx/frontend-app-course-authoring#readme>`__
|
||||
* Developing locally:
|
||||
https://github.com/openedx/frontend-app-course-authoring#readme
|
||||
* **React.js** application ``version: 17.0.2``
|
||||
* **Redux** store management ``version: 4.0.5``
|
||||
* It uses **Thunk** for adding to Redux the ability of returning
|
||||
functions.
|
||||
@@ -0,0 +1,66 @@
|
||||
Local Development & Testing
|
||||
===========================
|
||||
|
||||
Backend
|
||||
~~~~~~~
|
||||
|
||||
The backend endpoints lives in the ``edx-platform`` repo, specifically
|
||||
in this file: ``openedx/core/djangoapps/course_roles/views.py``
|
||||
|
||||
For quickly testing the different permissions and the flag change you
|
||||
can tweak the values directly in the above file.
|
||||
|
||||
* ``UserPermissionsView`` is in charge of returning the permissions, so
|
||||
for sending the permissions you want to check, you could do something
|
||||
like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
permissions = {
|
||||
'user_id': user_id,
|
||||
'course_key': str(course_key),
|
||||
#'permissions': sorted(permission.value.name for permission in permissions_set),
|
||||
'permissions': ['the_permissions_being_tested']
|
||||
}
|
||||
return Response(permissions)
|
||||
|
||||
By making this change, the permissions object will be bypassed and
|
||||
send a plain array with the specific permissions being tested.
|
||||
|
||||
|
||||
* ``UserPermissionsFlagView`` is in charge of returning the flag value
|
||||
(boolean), so you can easily turn the boolean like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
#payload = {'enabled': use_permission_checks()}
|
||||
payload = {'enabled': true}
|
||||
return Response(payload)
|
||||
|
||||
Flags
|
||||
~~~~~
|
||||
|
||||
You’ll need at least 2 flags to start:
|
||||
|
||||
* The basic flag for enabling the backend permissions system: ``course_roles.use_permission_checks``.
|
||||
|
||||
* The flag for enabling the page you want to test, for instance Course Team: ``contentstore.new_studio_mfe.use_new_course_team_page``.
|
||||
|
||||
All flags for enabling pages in the Studio MFE are listed
|
||||
`here <https://2u-internal.atlassian.net/wiki/x/CQCcHQ>`__.
|
||||
|
||||
Flags can be added by:
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
* Enter to ``http://localhost:18000/admin/``.
|
||||
* Log in as an admin.
|
||||
* Go to ``http://localhost:18000/admin/waffle/flag/``.
|
||||
* Click on ``+ADD FLAG`` button at the top right of the page and add
|
||||
the flag you need.
|
||||
|
||||
Testing
|
||||
~~~~~~~
|
||||
|
||||
For unit testing you run the npm script included in the ``package.json``, you can use it plainly for testing all components at once: ``npm run test``.
|
||||
|
||||
Or you can test one file at a time: ``npm run test path-to-file``.
|
||||
@@ -0,0 +1,62 @@
|
||||
Permissions Check implementation
|
||||
================================
|
||||
|
||||
For the permissions checks we basically hit 2 endpoints from the
|
||||
``edx-platform`` repo:
|
||||
|
||||
* **Permissions**:
|
||||
``/api/course_roles/v1/user_permissions/?course_id=[course_key]&user_id=[user_id]``
|
||||
|
||||
Which will return this structure:
|
||||
|
||||
.. code-block:: js
|
||||
|
||||
permissions = {
|
||||
'user_id': [user_id],
|
||||
'course_key': [course_key],
|
||||
'permissions': ['permission_1', 'permission_2']
|
||||
}
|
||||
|
||||
* **Permissions enabled** (which returns the boolean flag value): ``/api/course_roles/v1/user_permissions/enabled/``
|
||||
|
||||
The basic scaffolding for *fetching* and *storing* the permissions is located in the ``src/generic/data`` folder:
|
||||
|
||||
* ``api.js``: Exposes the ``getUserPermissions(courseId)`` and ``getUserPermissionsEnabledFlag()`` methods.
|
||||
* ``selectors.js``: Exposes the selectors ``getUserPermissions`` and ``getUserPermissionsEnabled`` to be used by ``useSelector()``.
|
||||
* ``slice.js``: Exposes the ``updateUserPermissions`` and ``updateUserPermissionsEnabled`` methods that will be used by the ``thunks.js`` file for dispatching and storing.
|
||||
* ``thunks.js``: Exposes the ``fetchUserPermissionsQuery(courseId)`` and ``fetchUserPermissionsEnabledFlag()`` methods for fetching.
|
||||
|
||||
In the ``src/generic/hooks.jsx`` we created a custom hook for exposing the ``checkPermission`` method, so that way we can call
|
||||
this method from any page and pass the permission we want to check for the current logged in user.
|
||||
|
||||
In this example on the ``src/course-team/CourseTeam.jsx`` page, we use the hook for checking if the current user has the ``manage_all_users``
|
||||
permission:
|
||||
|
||||
1. First, we import the hook (line 1).
|
||||
|
||||
2. Then we call the ``checkPermission`` method and assign it to a const (line 2).
|
||||
|
||||
3. Finally we use the const for showing or hiding a button (line 8).
|
||||
|
||||
.. code-block:: js
|
||||
|
||||
1. import { useUserPermissions } from '../generic/hooks';
|
||||
2. const hasManageAllUsersPerm = checkPermission('manage_all_users');
|
||||
|
||||
3. <SubHeader
|
||||
4. title={intl.formatMessage(messages.headingTitle)}
|
||||
5. subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
6. headerActions={(
|
||||
7. isAllowActions ||
|
||||
8. hasManageAllUsersPerm
|
||||
9. ) && (
|
||||
10. <Button
|
||||
11. variant="primary"
|
||||
12. iconBefore={IconAdd}
|
||||
13. size="sm"
|
||||
14. onClick={openForm}
|
||||
15. >
|
||||
16. {intl.formatMessage(messages.addNewMemberButton)}
|
||||
17. </Button>
|
||||
18. )}
|
||||
19. />
|
||||
@@ -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;
|
||||
@@ -1,4 +1,4 @@
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFilesAfterEnv: [
|
||||
@@ -11,7 +11,6 @@ module.exports = createConfig('jest', {
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^lodash-es$': 'lodash',
|
||||
'^CourseAuthoring/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
modulePathIgnorePatterns: [
|
||||
'/src/pages-and-resources/utils.test.jsx',
|
||||
|
||||
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
|
||||
31877
package-lock.json
generated
31877
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
125
package.json
125
package.json
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@edx/frontend-app-authoring",
|
||||
"name": "@edx/frontend-app-course-authoring",
|
||||
"version": "0.1.0",
|
||||
"description": "Frontend application template",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/openedx/frontend-app-authoring.git"
|
||||
"url": "git+https://github.com/openedx/frontend-app-course-authoring.git"
|
||||
},
|
||||
"browserslist": [
|
||||
"extends @edx/browserslist-config"
|
||||
@@ -12,113 +12,94 @@
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "fedx-scripts formatjs extract",
|
||||
"stylelint": "stylelint \"plugins/**/*.scss\" \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
|
||||
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
|
||||
"lint:fix": "npm run stylelint -- --fix && fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
|
||||
"stylelint": "stylelint \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
|
||||
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"start:with-theme": "paragon install-theme && npm start && npm install",
|
||||
"dev": "PUBLIC_PATH=/authoring/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
|
||||
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
|
||||
"test:ci": "TZ=UTC fedx-scripts jest --silent --coverage --passWithNoTests",
|
||||
"types": "tsc --noEmit"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run lint"
|
||||
}
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/openedx/frontend-app-authoring#readme",
|
||||
"homepage": "https://github.com/openedx/frontend-app-course-authoring#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/openedx/frontend-app-authoring/issues"
|
||||
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/lang-xml": "^6.0.0",
|
||||
"@codemirror/lint": "^6.2.1",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-component-footer": "^14.3.0",
|
||||
"@edx/frontend-component-header": "^6.2.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^7.2.0",
|
||||
"@edx/frontend-platform": "^8.3.1",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-ai-translations": "^1.4.0",
|
||||
"@edx/frontend-component-footer": "^12.3.0",
|
||||
"@edx/frontend-component-header": "^4.7.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^2.0.0",
|
||||
"@edx/frontend-lib-content-components": "^1.178.2",
|
||||
"@edx/frontend-platform": "5.6.1",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
||||
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
|
||||
"@openedx-plugins/course-app-ora_settings": "file:plugins/course-apps/ora_settings",
|
||||
"@openedx-plugins/course-app-proctoring": "file:plugins/course-apps/proctoring",
|
||||
"@openedx-plugins/course-app-progress": "file:plugins/course-apps/progress",
|
||||
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
|
||||
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
|
||||
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
|
||||
"@openedx/frontend-build": "^14.3.3",
|
||||
"@openedx/frontend-plugin-framework": "^1.6.0",
|
||||
"@openedx/frontend-slot-footer": "^1.2.0",
|
||||
"@openedx/paragon": "^22.16.0",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@edx/paragon": "^21.5.6",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tanstack/react-query": "4.36.1",
|
||||
"@tinymce/tinymce-react": "^3.14.0",
|
||||
"classnames": "2.5.1",
|
||||
"codemirror": "^6.0.0",
|
||||
"broadcast-channel": "^7.0.0",
|
||||
"classnames": "2.2.6",
|
||||
"core-js": "3.8.1",
|
||||
"email-validator": "2.0.4",
|
||||
"fast-xml-parser": "^4.0.10",
|
||||
"file-saver": "^2.0.5",
|
||||
"formik": "2.4.6",
|
||||
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
|
||||
"formik": "2.2.6",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"meilisearch": "^0.41.0",
|
||||
"moment": "2.30.1",
|
||||
"moment-shortformat": "^2.1.0",
|
||||
"npm": "^10.8.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"moment": "2.29.4",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "17.0.2",
|
||||
"react-datepicker": "^4.13.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-dom": "17.0.2",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-onclickoutside": "^6.13.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-responsive": "9.0.2",
|
||||
"react-router": "6.27.0",
|
||||
"react-router-dom": "6.27.0",
|
||||
"react-select": "5.8.0",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-router": "6.16.0",
|
||||
"react-router-dom": "6.16.0",
|
||||
"react-textarea-autosize": "^8.4.1",
|
||||
"react-transition-group": "4.4.5",
|
||||
"redux": "4.0.5",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.4.1",
|
||||
"reselect": "^4.1.5",
|
||||
"start": "^5.1.0",
|
||||
"tinymce": "^5.10.4",
|
||||
"regenerator-runtime": "0.13.7",
|
||||
"universal-cookie": "^4.0.4",
|
||||
"uuid": "^3.4.0",
|
||||
"xmlchecker": "^0.1.0",
|
||||
"yup": "0.31.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/react-unit-test-utils": "^4.0.0",
|
||||
"@edx/stylelint-config-edx": "2.3.3",
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-build": "13.0.5",
|
||||
"@edx/react-unit-test-utils": "^1.7.0",
|
||||
"@edx/reactifex": "^1.0.3",
|
||||
"@edx/stylelint-config-edx": "^2.3.0",
|
||||
"@edx/typescript-config": "^1.0.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.8",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"glob": "7.2.3",
|
||||
"husky": "^7.0.4",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"redux-mock-store": "^1.5.4"
|
||||
"react-test-renderer": "17.0.2",
|
||||
"reactifex": "1.1.1",
|
||||
"ts-loader": "^9.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"decode-uri-component": ">=0.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* Settings widget for the "calculator" Course App.
|
||||
* @param {{onClose: () => void}} props
|
||||
*/
|
||||
const CalculatorSettings = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId="calculator"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableCalculatorHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableCalculatorLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableCalculatorLink)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
CalculatorSettings.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default CalculatorSettings;
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-calculator",
|
||||
"version": "0.1.0",
|
||||
"description": "Calculator configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* Settings widget for the "edxnotes" Course App.
|
||||
* @param {{onClose: () => void}} props
|
||||
*/
|
||||
const NotesSettings = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId="edxnotes"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableNotesHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableNotesLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableNotesLink)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
NotesSettings.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default NotesSettings;
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-edxnotes",
|
||||
"version": "0.1.0",
|
||||
"description": "edxnotes configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-learning_assistant",
|
||||
"version": "0.1.0",
|
||||
"description": "Learning Assistant configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-live",
|
||||
"version": "0.1.0",
|
||||
"description": "Live course configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"@reduxjs/toolkit": "*",
|
||||
"lodash": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"react-redux": "*",
|
||||
"react-router-dom": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
import { Info } from '@openedx/paragon/icons';
|
||||
import { updateModel, useModel } from 'CourseAuthoring/generic/model-store';
|
||||
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||
import Loading from 'CourseAuthoring/generic/Loading';
|
||||
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
||||
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
|
||||
import { useAppSetting, useIsMobile } from 'CourseAuthoring/utils';
|
||||
import { getLoadingStatus, getSavingStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
|
||||
import { updateSavingStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const ORASettings = ({ onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { formatMessage } = useIntl();
|
||||
const alertRef = useRef(null);
|
||||
const updateSettingsRequestStatus = useSelector(getSavingStatus);
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
const isMobile = useIsMobile();
|
||||
const modalVariant = isMobile ? 'dark' : 'default';
|
||||
const appId = 'ora_settings';
|
||||
const appInfo = useModel('courseApps', appId);
|
||||
|
||||
const [enableFlexiblePeerGrade, saveSetting] = useAppSetting(
|
||||
'forceOnFlexiblePeerOpenassessments',
|
||||
);
|
||||
const initialFormValues = { enableFlexiblePeerGrade };
|
||||
|
||||
const [formValues, setFormValues] = useState(initialFormValues);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
|
||||
const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default';
|
||||
const handleSettingsSave = (values) => saveSetting(values.enableFlexiblePeerGrade);
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
let success = true;
|
||||
event.preventDefault();
|
||||
|
||||
success = success && await handleSettingsSave(formValues);
|
||||
await setSaveError(!success);
|
||||
if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) {
|
||||
success = await dispatch(updateModel({
|
||||
modelType: 'courseApps',
|
||||
model: {
|
||||
id: appId, enabled: formValues.enableFlexiblePeerGrade,
|
||||
},
|
||||
}));
|
||||
}
|
||||
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormValues({ enableFlexiblePeerGrade: e.target.checked });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) {
|
||||
dispatch(updateSavingStatus({ status: '' }));
|
||||
onClose();
|
||||
}
|
||||
}, [updateSettingsRequestStatus]);
|
||||
|
||||
const renderBody = () => {
|
||||
switch (loadingStatus) {
|
||||
case RequestStatus.SUCCESSFUL:
|
||||
return (
|
||||
<>
|
||||
{saveError && (
|
||||
<Alert variant="danger" icon={Info} ref={alertRef}>
|
||||
<Alert.Heading>
|
||||
{formatMessage(messages.errorSavingTitle)}
|
||||
</Alert.Heading>
|
||||
{formatMessage(messages.errorSavingMessage)}
|
||||
</Alert>
|
||||
)}
|
||||
<FormSwitchGroup
|
||||
id="enable-flexible-peer-grade"
|
||||
name="enableFlexiblePeerGrade"
|
||||
label={(
|
||||
<div className="d-flex align-items-center">
|
||||
{formatMessage(messages.enableFlexPeerGradeLabel)}
|
||||
{formValues.enableFlexiblePeerGrade && (
|
||||
<Badge className="ml-2" variant="success" data-testid="enable-badge">
|
||||
{formatMessage(messages.enabledBadgeLabel)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
helpText={(
|
||||
<div>
|
||||
<p>{formatMessage(messages.enableFlexPeerGradeHelp)}</p>
|
||||
<span className="py-3">
|
||||
<Hyperlink
|
||||
className="text-primary-500 small"
|
||||
destination={appInfo.documentationLinks?.learnMoreConfiguration}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{formatMessage(messages.ORASettingsHelpLink)}
|
||||
</Hyperlink>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
onChange={handleChange}
|
||||
checked={formValues.enableFlexiblePeerGrade}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case RequestStatus.DENIED:
|
||||
return <PermissionDeniedAlert />;
|
||||
case RequestStatus.FAILED:
|
||||
return <ConnectionErrorAlert />;
|
||||
default:
|
||||
return <Loading />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
title={formatMessage(messages.heading)}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
variant={modalVariant}
|
||||
hasCloseButton={isMobile}
|
||||
isFullscreenScroll
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
<Form onSubmit={handleSubmit} data-testid="proctoringForm">
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
{formatMessage(messages.heading)}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
{renderBody()}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer className="p-4">
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{formatMessage(messages.cancelLabel)}
|
||||
</ModalDialog.CloseButton>
|
||||
<StatefulButton
|
||||
labels={{
|
||||
default: formatMessage(messages.saveLabel),
|
||||
pending: formatMessage(messages.pendingSaveLabel),
|
||||
}}
|
||||
description="Form save button"
|
||||
data-testid="submissionButton"
|
||||
disabled={submitButtonState === RequestStatus.IN_PROGRESS}
|
||||
state={submitButtonState}
|
||||
type="submit"
|
||||
/>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</Form>
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
|
||||
ORASettings.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ORASettings;
|
||||
@@ -1,152 +0,0 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import { getCourseAppsApiUrl, getCourseAdvancedSettingsApiUrl } from 'CourseAuthoring/pages-and-resources/data/api';
|
||||
import { fetchCourseApps, fetchCourseAppSettings } from 'CourseAuthoring/pages-and-resources/data/thunks';
|
||||
import ORASettings from './Settings';
|
||||
import messages from './messages';
|
||||
import {
|
||||
courseId,
|
||||
inititalState,
|
||||
} from './factories/mockData';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const oraSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
|
||||
|
||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
||||
ReactDOM.createPortal = jest.fn(node => node);
|
||||
|
||||
const renderComponent = () => (
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<PagesAndResourcesProvider courseId={courseId}>
|
||||
<MemoryRouter initialEntries={[oraSettingsUrl]}>
|
||||
<Routes>
|
||||
<Route path={oraSettingsUrl} element={<PageWrap><ORASettings onClose={jest.fn()} /></PageWrap>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PagesAndResourcesProvider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
)
|
||||
);
|
||||
|
||||
const mockStore = async ({
|
||||
apiStatus,
|
||||
enabled,
|
||||
}) => {
|
||||
const settings = ['forceOnFlexiblePeerOpenassessments'];
|
||||
const fetchCourseAppsUrl = `${getCourseAppsApiUrl()}/${courseId}`;
|
||||
const fetchAdvancedSettingsUrl = `${getCourseAdvancedSettingsApiUrl()}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(fetchCourseAppsUrl).reply(
|
||||
200,
|
||||
[{
|
||||
allowed_operations: { enable: false, configure: true },
|
||||
description: 'setting',
|
||||
documentation_links: { learnMoreConfiguration: '' },
|
||||
enabled,
|
||||
id: 'ora_settings',
|
||||
name: 'Flexible Peer Grading for ORAs',
|
||||
}],
|
||||
);
|
||||
axiosMock.onGet(fetchAdvancedSettingsUrl).reply(
|
||||
apiStatus,
|
||||
{ force_on_flexible_peer_openassessments: { value: enabled } },
|
||||
);
|
||||
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseAppSettings(courseId, settings), store.dispatch);
|
||||
};
|
||||
|
||||
describe('ORASettings', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(inititalState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('Flexible peer grading configuration modal is visible', async () => {
|
||||
renderComponent();
|
||||
expect(screen.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays "Configure Flexible Peer Grading" heading', async () => {
|
||||
renderComponent();
|
||||
const headingElement = screen.getByText(messages.heading.defaultMessage);
|
||||
|
||||
expect(headingElement).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays loading component', () => {
|
||||
renderComponent();
|
||||
const loadingElement = screen.getByRole('status');
|
||||
|
||||
expect(within(loadingElement).getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays Connection Error Alert', async () => {
|
||||
await mockStore({ apiStatus: 404, enabled: true });
|
||||
renderComponent();
|
||||
const errorAlert = screen.getByRole('alert');
|
||||
|
||||
expect(within(errorAlert).getByText('We encountered a technical error when loading this page.', { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays Permissions Error Alert', async () => {
|
||||
await mockStore({ apiStatus: 403, enabled: true });
|
||||
renderComponent();
|
||||
const errorAlert = screen.getByRole('alert');
|
||||
|
||||
expect(within(errorAlert).getByText('You are not authorized to view this page', { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
|
||||
renderComponent();
|
||||
await mockStore({ apiStatus: 200, enabled: true });
|
||||
|
||||
waitFor(() => {
|
||||
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
||||
const enableBadge = screen.getByTestId('enable-badge');
|
||||
|
||||
expect(label).toBeVisible();
|
||||
|
||||
expect(enableBadge).toHaveTextContent('Enabled');
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays title, helper text and hides badge when flexible peer grading button is disabled', async () => {
|
||||
renderComponent();
|
||||
await mockStore({ apiStatus: 200, enabled: false });
|
||||
|
||||
const label = await screen.findByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
||||
const enableBadge = screen.queryByTestId('enable-badge');
|
||||
|
||||
expect(label).toBeVisible();
|
||||
|
||||
expect(enableBadge).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.pages-resources.ora.heading',
|
||||
defaultMessage: 'Configure Flexible Peer Grading',
|
||||
description: 'Title for the modal dialog header',
|
||||
},
|
||||
ORASettingsHelpLink: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.link',
|
||||
defaultMessage: 'Learn more about open response assessment settings',
|
||||
description: 'Descriptive text for the hyperlink to the docs site',
|
||||
},
|
||||
enableFlexPeerGradeLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.label',
|
||||
defaultMessage: 'Flex Peer Grading',
|
||||
description: 'Label for form switch',
|
||||
},
|
||||
enableFlexPeerGradeHelp: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.help',
|
||||
defaultMessage: 'Turn on Flexible Peer Grading for all open response assessments in the course with peer grading.',
|
||||
description: 'Help text describing what happens when the switch is enabled',
|
||||
},
|
||||
enabledBadgeLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.enabled-badge.label',
|
||||
defaultMessage: 'Enabled',
|
||||
description: 'Label for badge that show users that a setting is enabled',
|
||||
},
|
||||
cancelLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.cancel-button.label',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Label for button that cancels user changes',
|
||||
},
|
||||
saveLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-button.label',
|
||||
defaultMessage: 'Save',
|
||||
description: 'Label for button that saves user changes',
|
||||
},
|
||||
pendingSaveLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.pending-save-button.label',
|
||||
defaultMessage: 'Saving',
|
||||
description: 'Label for button that has pending api save calls',
|
||||
},
|
||||
errorSavingTitle: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.title',
|
||||
defaultMessage: 'We couldn\'t apply your changes.',
|
||||
},
|
||||
errorSavingMessage: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.message',
|
||||
defaultMessage: 'Please check your entries and try again.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-ora_settings",
|
||||
"version": "0.1.0",
|
||||
"description": "Open Response Assessment configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"react-redux": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-proctoring",
|
||||
"version": "0.1.0",
|
||||
"description": "Proctoring configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"classnames": "*",
|
||||
"email-validator": "*",
|
||||
"react": "*",
|
||||
"prop-types": "*",
|
||||
"moment": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-progress",
|
||||
"version": "0.1.0",
|
||||
"description": "Progress configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { useFormikContext } from 'formik';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import GroupEditor from './GroupEditor';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('formik', () => ({
|
||||
...jest.requireActual('formik'),
|
||||
useFormikContext: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('GroupEditor', () => {
|
||||
const mockIntl = { formatMessage: jest.fn() };
|
||||
|
||||
const mockGroup = {
|
||||
id: '1',
|
||||
name: 'Test Group',
|
||||
description: 'Test Group Description',
|
||||
type: 'open',
|
||||
maxTeamSize: 5,
|
||||
};
|
||||
|
||||
const mockProps = {
|
||||
intl: mockIntl,
|
||||
fieldNameCommonBase: 'test',
|
||||
group: mockGroup,
|
||||
onDelete: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
onBlur: jest.fn(),
|
||||
errors: {},
|
||||
};
|
||||
|
||||
const renderComponent = (overrideProps = {}) => render(
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<GroupEditor {...mockProps} {...overrideProps} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
useFormikContext.mockReturnValue({
|
||||
touched: {},
|
||||
errors: {},
|
||||
handleChange: jest.fn(),
|
||||
handleBlur: jest.fn(),
|
||||
setFieldError: jest.fn(),
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders without errors', () => {
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test('renders the group name and description', () => {
|
||||
const { getByText } = renderComponent();
|
||||
expect(getByText('Test Group')).toBeInTheDocument();
|
||||
expect(getByText('Test Group Description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('group types messages', () => {
|
||||
test('group type open message', () => {
|
||||
const { getByLabelText, getByText } = renderComponent();
|
||||
const expandButton = getByLabelText('Expand group editor');
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
fireEvent.click(expandButton);
|
||||
expect(getByText(messages.groupTypeOpenDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('group type public_managed message', () => {
|
||||
const publicManagedGroupMock = {
|
||||
id: '2',
|
||||
name: 'Test Group',
|
||||
description: 'Test Group Description',
|
||||
type: 'public_managed',
|
||||
maxTeamSize: 5,
|
||||
};
|
||||
const { getByLabelText, getByText } = renderComponent({ group: publicManagedGroupMock });
|
||||
const expandButton = getByLabelText('Expand group editor');
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
fireEvent.click(expandButton);
|
||||
expect(getByText(messages.groupTypePublicManagedDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('group type private_managed message', () => {
|
||||
const privateManagedGroupMock = {
|
||||
id: '3',
|
||||
name: 'Test Group',
|
||||
description: 'Test Group Description',
|
||||
type: 'private_managed',
|
||||
maxTeamSize: 5,
|
||||
};
|
||||
const { getByLabelText, getByText } = renderComponent({ group: privateManagedGroupMock });
|
||||
const expandButton = getByLabelText('Expand group editor');
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
fireEvent.click(expandButton);
|
||||
expect(getByText(messages.groupTypePrivateManagedDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-teams",
|
||||
"version": "0.1.0",
|
||||
"description": "Teams configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"formik": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"uuid": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
||||
|
||||
/**
|
||||
* Check if a group type is enabled by the current configuration.
|
||||
* This is a temporary workaround to disable the OPEN MANAGED team type until it is fully adopted.
|
||||
* For more information, see: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3885760525/Open+Managed+Group+Type
|
||||
* @param {string} groupType - the group type to check
|
||||
* @returns {boolean} - true if the group type is enabled
|
||||
*/
|
||||
export const isGroupTypeEnabled = (groupType) => {
|
||||
const enabledTypesByDefault = [
|
||||
GroupTypes.OPEN,
|
||||
GroupTypes.PUBLIC_MANAGED,
|
||||
GroupTypes.PRIVATE_MANAGED,
|
||||
];
|
||||
const enabledTypesByConfig = {
|
||||
[GroupTypes.OPEN_MANAGED]: getConfig().ENABLE_OPEN_MANAGED_TEAM_TYPE,
|
||||
};
|
||||
return enabledTypesByDefault.includes(groupType) || enabledTypesByConfig[groupType] || false;
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
||||
import { isGroupTypeEnabled } from './utils';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() }));
|
||||
|
||||
describe('teams utils', () => {
|
||||
describe('isGroupTypeEnabled', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('returns true if the group type is enabled', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false });
|
||||
expect(isGroupTypeEnabled(GroupTypes.OPEN)).toBe(true);
|
||||
expect(isGroupTypeEnabled(GroupTypes.PUBLIC_MANAGED)).toBe(true);
|
||||
expect(isGroupTypeEnabled(GroupTypes.PRIVATE_MANAGED)).toBe(true);
|
||||
});
|
||||
test('returns false if the OPEN_MANAGED group is not enabled', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false });
|
||||
expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true if the OPEN_MANAGED group is enabled', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
|
||||
expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false if the group is invalid', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
|
||||
expect(isGroupTypeEnabled('FOO')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false if the group is null', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
|
||||
expect(isGroupTypeEnabled(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-wiki",
|
||||
"version": "0.1.0",
|
||||
"description": "Wiki configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
Xpert Unit Summaries Configuration Plugin
|
||||
=========================================
|
||||
|
||||
Install this using ``npm install plugins/course-apps/xpert_unit_summary/ --no-save``.
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-xpert_unit_summary",
|
||||
"version": "0.1.0",
|
||||
"description": "Xpert Unit Summaries configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"formik": "*",
|
||||
"prop-types": "*",
|
||||
"yup": "*",
|
||||
"react": "*",
|
||||
"react-redux": "*",
|
||||
"react-router-dom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,15 @@
|
||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@edx/frontend-lib-content-components"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": false,
|
||||
"schedule": [
|
||||
"after 1am",
|
||||
"before 11pm"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,29 +5,53 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import Header from './header';
|
||||
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
|
||||
import { fetchCourseDetail } from './data/thunks';
|
||||
import { useModel } from './generic/model-store';
|
||||
import NotFoundAlert from './generic/NotFoundAlert';
|
||||
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
||||
import { fetchOnlyStudioHomeData } from './studio-home/data/thunks';
|
||||
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
|
||||
import { RequestStatus } from './data/constants';
|
||||
import Loading from './generic/Loading';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from './generic/data/thunks';
|
||||
import { getUserPermissions } from './generic/data/selectors';
|
||||
|
||||
const AppHeader = ({
|
||||
courseNumber, courseOrg, courseTitle, courseId,
|
||||
}) => (
|
||||
<Header
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
);
|
||||
|
||||
AppHeader.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
courseNumber: PropTypes.string,
|
||||
courseOrg: PropTypes.string,
|
||||
courseTitle: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
AppHeader.defaultProps = {
|
||||
courseNumber: null,
|
||||
courseOrg: null,
|
||||
};
|
||||
|
||||
const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
const userPermissions = useSelector(getUserPermissions);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseDetail(courseId));
|
||||
dispatch(fetchWaffleFlags(courseId));
|
||||
dispatch(fetchUserPermissionsEnabledFlag());
|
||||
if (!userPermissions) {
|
||||
dispatch(fetchUserPermissionsQuery(courseId));
|
||||
}
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchOnlyStudioHomeData());
|
||||
}, []);
|
||||
|
||||
const courseDetail = useModel('courseDetails', courseId);
|
||||
|
||||
const courseNumber = courseDetail ? courseDetail.number : null;
|
||||
@@ -50,23 +74,23 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
|
||||
{/* While V2 Editors are temporarily served from their own pages
|
||||
using url pattern containing /editor/,
|
||||
we shouldn't have the header and footer on these pages.
|
||||
This functionality will be removed in TNL-9591 */}
|
||||
{inProgress ? !isEditor && <Loading />
|
||||
: (!isEditor && (
|
||||
<Header
|
||||
number={courseNumber}
|
||||
org={courseOrg}
|
||||
title={courseTitle}
|
||||
contextId={courseId}
|
||||
<AppHeader
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{children}
|
||||
{!inProgress && !isEditor && <StudioFooterSlot />}
|
||||
{!inProgress && !isEditor && <StudioFooter />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import React from 'react';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import initializeStore from './store';
|
||||
import CourseAuthoringPage from './CourseAuthoringPage';
|
||||
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
||||
import { executeThunk } from './utils';
|
||||
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
||||
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
|
||||
import { getApiWaffleFlagsUrl } from './data/api';
|
||||
import { initializeMocks, render } from './testUtils';
|
||||
import { fetchCourseDetail } from './data/thunks';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
let mockPathname = '/evilguy/';
|
||||
@@ -19,14 +25,17 @@ jest.mock('react-router-dom', () => ({
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mocks = initializeMocks();
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
axiosMock
|
||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
||||
.reply(200, {});
|
||||
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
describe('Editor Pages Load no header', () => {
|
||||
@@ -42,9 +51,13 @@ describe('Editor Pages Load no header', () => {
|
||||
mockPathname = '/editor/';
|
||||
await mockStoreSuccess();
|
||||
const wrapper = render(
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</CourseAuthoringPage>
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</CourseAuthoringPage>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
expect(wrapper.queryByRole('status')).not.toBeInTheDocument();
|
||||
@@ -53,9 +66,13 @@ describe('Editor Pages Load no header', () => {
|
||||
mockPathname = '/evilguy/';
|
||||
await mockStoreSuccess();
|
||||
const wrapper = render(
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</CourseAuthoringPage>
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</CourseAuthoringPage>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
expect(wrapper.queryByRole('status')).toBeInTheDocument();
|
||||
@@ -83,7 +100,14 @@ describe('Course authoring page', () => {
|
||||
};
|
||||
test('renders not found page on non-existent course key', async () => {
|
||||
await mockStoreNotFound();
|
||||
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
|
||||
const wrapper = render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
|
||||
});
|
||||
test('does not render not found page on other kinds of error', async () => {
|
||||
@@ -94,9 +118,13 @@ describe('Course authoring page', () => {
|
||||
// found alert is not present.
|
||||
const contentTestId = 'courseAuthoringPageContent';
|
||||
const wrapper = render(
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<div data-testid={contentTestId} />
|
||||
</CourseAuthoringPage>
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<div data-testid={contentTestId} />
|
||||
</CourseAuthoringPage>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
} from 'react-router-dom';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { PageWrap } from '@edx/frontend-platform/react';
|
||||
import { Textbooks } from 'CourseAuthoring/textbooks';
|
||||
import CourseAuthoringPage from './CourseAuthoringPage';
|
||||
import { PagesAndResources } from './pages-and-resources';
|
||||
import EditorContainer from './editors/EditorContainer';
|
||||
@@ -18,15 +17,9 @@ import { GradingSettings } from './grading-settings';
|
||||
import CourseTeam from './course-team/CourseTeam';
|
||||
import { CourseUpdates } from './course-updates';
|
||||
import { CourseUnit } from './course-unit';
|
||||
import { Certificates } from './certificates';
|
||||
import CourseExportPage from './export-page/CourseExportPage';
|
||||
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
|
||||
import CourseImportPage from './import-page/CourseImportPage';
|
||||
import { DECODED_ROUTES } from './constants';
|
||||
import CourseChecklist from './course-checklist';
|
||||
import GroupConfigurations from './group-configurations';
|
||||
import { CourseLibraries } from './course-libraries';
|
||||
import { IframeProvider } from './generic/hooks/context/iFrameContext';
|
||||
|
||||
/**
|
||||
* As of this writing, these routes are mounted at a path prefixed with the following:
|
||||
@@ -58,10 +51,6 @@ const CourseAuthoringRoutes = () => {
|
||||
path="course_info"
|
||||
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="libraries"
|
||||
element={<PageWrap><CourseLibraries courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="assets"
|
||||
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
|
||||
@@ -84,18 +73,17 @@ const CourseAuthoringRoutes = () => {
|
||||
/>
|
||||
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
element={<PageWrap><IframeProvider><CourseUnit courseId={courseId} /></IframeProvider></PageWrap>}
|
||||
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
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
|
||||
path="editor/:blockType/:blockId?"
|
||||
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
|
||||
element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? <PageWrap><EditorContainer courseId={courseId} /></PageWrap> : null}
|
||||
/>
|
||||
<Route
|
||||
path="settings/details"
|
||||
@@ -109,10 +97,6 @@ const CourseAuthoringRoutes = () => {
|
||||
path="course_team"
|
||||
element={<PageWrap><CourseTeam courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="group_configurations"
|
||||
element={<PageWrap><GroupConfigurations courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="settings/advanced"
|
||||
element={<PageWrap><AdvancedSettings courseId={courseId} /></PageWrap>}
|
||||
@@ -125,22 +109,6 @@ const CourseAuthoringRoutes = () => {
|
||||
path="export"
|
||||
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="optimizer"
|
||||
element={<PageWrap><CourseOptimizerPage courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="checklists"
|
||||
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<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>
|
||||
</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 { executeThunk } from './utils';
|
||||
import { getApiWaffleFlagsUrl } from './data/api';
|
||||
import { fetchWaffleFlags } from './data/thunks';
|
||||
import {
|
||||
screen, initializeMocks, render, waitFor,
|
||||
} from './testUtils';
|
||||
import initializeStore from './store';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const pagesAndResourcesMockText = 'Pages And Resources';
|
||||
@@ -21,10 +21,9 @@ jest.mock('react-router-dom', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the TinyMceWidget
|
||||
jest.mock('./editors/sharedComponents/TinyMceWidget', () => ({
|
||||
__esModule: true, // Required to mock a default export
|
||||
default: () => <div>Widget</div>,
|
||||
// Mock the TinyMceWidget from frontend-lib-content-components
|
||||
jest.mock('@edx/frontend-lib-content-components', () => ({
|
||||
TinyMceWidget: () => <div>Widget</div>,
|
||||
Footer: () => <div>Footer</div>,
|
||||
prepareEditorRef: jest.fn(() => ({
|
||||
refReady: true,
|
||||
@@ -50,59 +49,68 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
|
||||
});
|
||||
|
||||
describe('<CourseAuthoringRoutes>', () => {
|
||||
beforeEach(async () => {
|
||||
const { axiosMock, reduxStore } = initializeMocks();
|
||||
store = reduxStore;
|
||||
axiosMock
|
||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
||||
.reply(200, {});
|
||||
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it('renders the PagesAndResources component when the pages and resources route is active', async () => {
|
||||
fit('renders the PagesAndResources component when the pages and resources route is active', () => {
|
||||
render(
|
||||
<CourseAuthoringRoutes />,
|
||||
{ routerProps: { initialEntries: ['/pages-and-resources'] } },
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/pages-and-resources']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the EditorContainer component when the course editor route is active', async () => {
|
||||
it('renders the EditorContainer component when the course editor route is active', () => {
|
||||
render(
|
||||
<CourseAuthoringRoutes />,
|
||||
{ routerProps: { initialEntries: ['/editor/video/block-id'] } },
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/editor/video/block-id']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
learningContextId: courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the VideoSelectorContainer component when the course videos route is active', async () => {
|
||||
it('renders the VideoSelectorContainer component when the course videos route is active', () => {
|
||||
render(
|
||||
<CourseAuthoringRoutes />,
|
||||
{ routerProps: { initialEntries: ['/editor/course-videos/block-id'] } },
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={['/editor/course-videos/block-id']}>
|
||||
<CourseAuthoringRoutes />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -1,98 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const AccessibilityBody = ({
|
||||
communityAccessibilityLink,
|
||||
email,
|
||||
}) => (
|
||||
<div className="mt-5">
|
||||
<header>
|
||||
<h2 className="mb-4 pb-1">
|
||||
<FormattedMessage {...messages.a11yBodyPageHeader} />
|
||||
</h2>
|
||||
</header>
|
||||
<Stack gap={2.5}>
|
||||
<div className="small">
|
||||
<FormattedMessage
|
||||
{...messages.a11yBodyIntroGraph}
|
||||
values={{
|
||||
communityAccessibilityLink: (
|
||||
<Hyperlink
|
||||
destination={communityAccessibilityLink}
|
||||
data-testid="accessibility-page-link"
|
||||
>
|
||||
Website Accessibility Policy
|
||||
</Hyperlink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="small">
|
||||
<FormattedMessage {...messages.a11yBodyStepsHeader} />
|
||||
</div>
|
||||
<ol className="small m-0">
|
||||
<li>
|
||||
<FormattedMessage
|
||||
{...messages.a11yBodyEmailHeading}
|
||||
values={{
|
||||
emailElement: (
|
||||
<MailtoLink
|
||||
to={email}
|
||||
data-testid="email-element"
|
||||
>
|
||||
{email}
|
||||
</MailtoLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<ul>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyNameEmail} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyInstitution} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyBarrier} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyTimeConstraints} />
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyReceipt} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyExtraInfo} />
|
||||
</li>
|
||||
</ol>
|
||||
<div className="small">
|
||||
<FormattedMessage
|
||||
{...messages.a11yBodyA11yFeedback}
|
||||
values={{
|
||||
emailElement: (
|
||||
<MailtoLink
|
||||
to={email}
|
||||
data-testid="email-element"
|
||||
>
|
||||
{email}
|
||||
</MailtoLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
|
||||
AccessibilityBody.propTypes = {
|
||||
communityAccessibilityLink: PropTypes.string.isRequired,
|
||||
email: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccessibilityBody);
|
||||
@@ -1,46 +0,0 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import initializeStore from '../../store';
|
||||
|
||||
import AccessibilityBody from './index';
|
||||
|
||||
let store;
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AccessibilityBody
|
||||
communityAccessibilityLink="http://example.com"
|
||||
email="example@example.com"
|
||||
/>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AccessibilityBody />', () => {
|
||||
describe('renders', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore({});
|
||||
});
|
||||
it('contains links', () => {
|
||||
renderComponent();
|
||||
expect(screen.getAllByTestId('email-element')).toHaveLength(2);
|
||||
expect(screen.getAllByTestId('accessibility-page-link')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
import AccessibilityBody from './AccessibilityBody';
|
||||
|
||||
export default AccessibilityBody;
|
||||
@@ -1,111 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
a11yBodyPolicyLink: {
|
||||
id: 'a11yBodyPolicyLink',
|
||||
defaultMessage: 'Website Accessibility Policy',
|
||||
description: 'Title for link to full accessibility policy.',
|
||||
},
|
||||
a11yBodyPageHeader: {
|
||||
id: 'a11yBodyPageHeader',
|
||||
defaultMessage: 'Individualized Accessibility Process for Course Creators',
|
||||
description: 'Heading for studio\'s accessibility policy page.',
|
||||
},
|
||||
a11yBodyIntroGraph: {
|
||||
id: 'a11yBodyIntroGraph',
|
||||
defaultMessage: `At edX, we seek to understand and respect the unique needs and perspectives of the edX global community.
|
||||
We value every course team and are committed to expanding access to all, including course team creators and authors with
|
||||
disabilities. To that end, we have adopted a {communityAccessibilityLink} and this process to allow course team creators
|
||||
and authors to request assistance if they are unable to develop and post content on our platform via Studio because of their
|
||||
disabilities.`,
|
||||
description: 'Introductory paragraph outlining why we care about accessibility, and what we\'re doing about it.',
|
||||
},
|
||||
a11yBodyStepsHeader: {
|
||||
id: 'a11yBodyStepsHeader',
|
||||
defaultMessage: 'Course team creators and authors needing such assistance should take the following steps:',
|
||||
description: 'Heading for list of steps authors can take for accessibility requests.',
|
||||
},
|
||||
a11yBodyEdxResponse: {
|
||||
id: 'a11yBodyEdxResponse',
|
||||
defaultMessage: `'We will communicate with you about your preferences and needs in determining the appropriate solution, although
|
||||
the ultimate decision will be ours, provided that the solution is effective and timely. The factors we will consider in choosing
|
||||
an accessibility solution are: effectiveness; timeliness (relative to your deadlines); ease of implementation; and ease of use for
|
||||
you. We will notify you of the decision and explain the basis for our decision within 10 business days of discussing with you.`,
|
||||
description: 'Paragraph outlining how we will select an accessibility solution.',
|
||||
},
|
||||
a11yBodyEdxFollowUp: {
|
||||
id: 'a11yBodyEdxFollowUp',
|
||||
defaultMessage: `Thereafter, we will communicate with you on a weekly basis regarding our evaluation, decision, and progress in
|
||||
implementing the accessibility solution. We will notify you when implementation of your accessibility solution is complete and
|
||||
will follow-up with you as may be necessary to see if the solution was effective.`,
|
||||
description: 'Paragraph outlining how we will follow-up with you during and after implementing an accessibility solution.',
|
||||
},
|
||||
a11yBodyOngoingSupport: {
|
||||
id: 'a11yBodyOngoingSupport',
|
||||
defaultMessage: 'EdX will provide ongoing technical support as needed and will address any additional issues that arise after the initial course creation.',
|
||||
description: 'A statement of ongoing support.',
|
||||
},
|
||||
a11yBodyA11yFeedback: {
|
||||
id: 'a11yBodyA11yFeedback',
|
||||
defaultMessage: 'Please direct any questions or suggestions on how to improve the accessibility of Studio to {emailElement} or use the form below. We welcome your feedback.',
|
||||
description: 'Contact information heading for those with accessibility issues or suggestions.',
|
||||
},
|
||||
a11yBodyEmailHeading: {
|
||||
id: 'a11yBodyEmailHeading',
|
||||
defaultMessage: 'Send an email to {emailElement} with the following information:',
|
||||
description: 'Heading for list of information required when you email us.',
|
||||
},
|
||||
a11yBodyNameEmail: {
|
||||
id: 'a11yBodyNameEmail',
|
||||
defaultMessage: 'your name and email address;',
|
||||
description: 'Your contact information.',
|
||||
},
|
||||
a11yBodyInstitution: {
|
||||
id: 'a11yBodyInstitution',
|
||||
defaultMessage: 'the edX member institution that you are affiliated with;',
|
||||
description: 'edX affiliate information.',
|
||||
},
|
||||
a11yBodyBarrier: {
|
||||
id: 'a11yBodyBarrier',
|
||||
defaultMessage: 'a brief description of the challenge or barrier to access that you are experiencing; and',
|
||||
description: 'Accessibility problem information.',
|
||||
},
|
||||
a11yBodyTimeConstraints: {
|
||||
id: 'a11yBodyTimeConstraints',
|
||||
defaultMessage: 'how soon you need access and for how long (e.g., a planned course start date or in connection with a course-related deadline such as a final essay).',
|
||||
description: 'Time contstraint information.',
|
||||
},
|
||||
a11yBodyReceipt: {
|
||||
id: 'a11yBodyReceipt',
|
||||
defaultMessage: 'The edX Support Team will respond to confirm receipt and forward your request to the edX Partner Manager for your institution and the edX Website Accessibility Specialist.',
|
||||
description: 'Paragraph outlining what steps edX will take immediately.',
|
||||
},
|
||||
a11yBodyExtraInfo: {
|
||||
id: 'a11yBodyExtraInfo',
|
||||
defaultMessage: `With guidance from the Website Accessibility Specialist, edX will contact you to discuss your request and gather
|
||||
additional information from you about your preferences and needs, to determine if there's a workable solution that edX is able to support.`,
|
||||
description: 'Paragraph outlining how and when edX will reach out to you.',
|
||||
},
|
||||
a11yBodyFixesListHeader: {
|
||||
id: 'a11yBodyFixesListHeader',
|
||||
defaultMessage: 'EdX will assist you promptly and thoroughly so that you are able to create content on the CMS within your time constraints. Such efforts may include, but are not limited to:',
|
||||
description: 'Heading for list of ways we might be able to assist.',
|
||||
},
|
||||
a11yBodyThirdParty: {
|
||||
id: 'a11yBodyThirdParty',
|
||||
defaultMessage: 'Purchasing a third-party tool or software for use on an individual basis to assist your use of Studio;',
|
||||
description: 'Buy third-party software.',
|
||||
},
|
||||
a11yBodyContractor: {
|
||||
id: 'a11yBodyContractor',
|
||||
defaultMessage: 'Engaging a trained independent contractor to provide real-time visual, verbal and physical assistance; or',
|
||||
description: 'Hire a contractor.',
|
||||
},
|
||||
a11yBodyCodeFix: {
|
||||
id: 'a11yBodyCodeFix',
|
||||
defaultMessage: 'Developing new code to implement a technical fix.',
|
||||
description: 'Make a technical fix.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,146 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
injectIntl, FormattedMessage, intlShape, FormattedDate, FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Alert, Form, Stack, StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { STATEFUL_BUTTON_STATES } from '../../constants';
|
||||
import submitAccessibilityForm from '../data/thunks';
|
||||
import useAccessibility from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
const AccessibilityForm = ({
|
||||
accessibilityEmail,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const {
|
||||
errors,
|
||||
values,
|
||||
isFormFilled,
|
||||
dispatch,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
hasErrorField,
|
||||
savingStatus,
|
||||
} = useAccessibility({ name: '', email: '', message: '' }, intl);
|
||||
|
||||
const formFields = [
|
||||
{
|
||||
label: intl.formatMessage(messages.accessibilityPolicyFormEmailLabel),
|
||||
name: 'email',
|
||||
value: values.email,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(messages.accessibilityPolicyFormNameLabel),
|
||||
name: 'name',
|
||||
value: values.name,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(messages.accessibilityPolicyFormMessageLabel),
|
||||
name: 'message',
|
||||
value: values.message,
|
||||
},
|
||||
];
|
||||
|
||||
const createButtonState = {
|
||||
labels: {
|
||||
default: intl.formatMessage(messages.accessibilityPolicyFormSubmitLabel),
|
||||
pending: intl.formatMessage(messages.accessibilityPolicyFormSubmittingFeedbackLabel),
|
||||
},
|
||||
disabledStates: [STATEFUL_BUTTON_STATES.pending],
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
dispatch(submitAccessibilityForm(values));
|
||||
};
|
||||
|
||||
const start = new Date('Mon Jan 29 2018 13:00:00 GMT (UTC)');
|
||||
const end = new Date('Fri Feb 2 2018 21:00:00 GMT (UTC)');
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="my-4">
|
||||
<FormattedMessage {...messages.accessibilityPolicyFormHeader} />
|
||||
</h2>
|
||||
{savingStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Alert variant="success">
|
||||
<Stack gap={2}>
|
||||
<div className="mb-2">
|
||||
<FormattedMessage {...messages.accessibilityPolicyFormSuccess} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
{...messages.accessibilityPolicyFormSuccessDetails}
|
||||
values={{
|
||||
day_start: (<FormattedDate value={start} weekday="long" />),
|
||||
time_start: (<FormattedTime value={start} timeZoneName="short" />),
|
||||
day_end: (<FormattedDate value={end} weekday="long" />),
|
||||
time_end: (<FormattedTime value={end} timeZoneName="short" />),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{savingStatus === RequestStatus.FAILED && (
|
||||
<Alert variant="danger">
|
||||
<div data-testid="rate-limit-alert">
|
||||
<FormattedMessage
|
||||
{...messages.accessibilityPolicyFormErrorHighVolume}
|
||||
values={{
|
||||
emailLink: <a href={`mailto:${accessibilityEmail}`}>{accessibilityEmail}</a>,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
<Form>
|
||||
{formFields.map((field) => (
|
||||
<Form.Group size="sm" key={field.label}>
|
||||
<Form.Control
|
||||
value={field.value}
|
||||
name={field.name}
|
||||
isInvalid={hasErrorField(field.name)}
|
||||
type={field.name === 'email' ? 'email' : null}
|
||||
as={field.name === 'message' ? 'textarea' : 'input'}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
floatingLabel={field.label}
|
||||
/>
|
||||
{hasErrorField(field.name) && (
|
||||
<Form.Control.Feedback type="invalid" data-testid={`error-feedback-${field.name}`}>
|
||||
{errors[field.name]}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
))}
|
||||
</Form>
|
||||
<ActionRow>
|
||||
<StatefulButton
|
||||
key="save-button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!isFormFilled}
|
||||
state={
|
||||
savingStatus === RequestStatus.IN_PROGRESS
|
||||
? STATEFUL_BUTTON_STATES.pending
|
||||
: STATEFUL_BUTTON_STATES.default
|
||||
}
|
||||
{...createButtonState}
|
||||
/>
|
||||
</ActionRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AccessibilityForm.propTypes = {
|
||||
accessibilityEmail: PropTypes.string.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccessibilityForm);
|
||||
@@ -1,164 +0,0 @@
|
||||
import {
|
||||
render,
|
||||
act,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
import AccessibilityForm from './index';
|
||||
import { getZendeskrUrl } from '../data/api';
|
||||
import messages from './messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const defaultProps = {
|
||||
accessibilityEmail: 'accessibilityTest@test.com',
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
accessibilityPage: {
|
||||
savingStatus: '',
|
||||
},
|
||||
};
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AccessibilityForm {...defaultProps} />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AccessibilityPolicyForm />', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
describe('renders', () => {
|
||||
beforeEach(() => {
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
it('correct number of form fields', () => {
|
||||
const formSections = screen.getAllByRole('textbox');
|
||||
const formButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||
expect(formSections).toHaveLength(3);
|
||||
expect(formButton).toBeVisible();
|
||||
});
|
||||
|
||||
it('hides StatusAlert on initial load', () => {
|
||||
expect(screen.queryAllByRole('alert')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusAlert', () => {
|
||||
let formSections;
|
||||
let submitButton;
|
||||
beforeEach(async () => {
|
||||
renderComponent();
|
||||
formSections = screen.getAllByRole('textbox');
|
||||
await act(async () => {
|
||||
userEvent.type(formSections[0], 'email@email.com');
|
||||
userEvent.type(formSections[1], 'test name');
|
||||
userEvent.type(formSections[2], 'feedback message');
|
||||
});
|
||||
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||
});
|
||||
|
||||
it('shows correct success message', async () => {
|
||||
axiosMock.onPost(getZendeskrUrl()).reply(200);
|
||||
await act(async () => {
|
||||
userEvent.click(submitButton);
|
||||
});
|
||||
const { savingStatus } = store.getState().accessibilityPage;
|
||||
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
||||
|
||||
expect(screen.getByText(messages.accessibilityPolicyFormSuccess.defaultMessage)).toBeVisible();
|
||||
|
||||
formSections.forEach(input => {
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows correct rate limiting message', async () => {
|
||||
axiosMock.onPost(getZendeskrUrl()).reply(429);
|
||||
await act(async () => {
|
||||
userEvent.click(submitButton);
|
||||
});
|
||||
const { savingStatus } = store.getState().accessibilityPage;
|
||||
expect(savingStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
||||
|
||||
expect(screen.getByTestId('rate-limit-alert')).toBeVisible();
|
||||
|
||||
formSections.forEach(input => {
|
||||
expect(input.value).not.toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
let formSections;
|
||||
let submitButton;
|
||||
beforeEach(async () => {
|
||||
renderComponent();
|
||||
formSections = screen.getAllByRole('textbox');
|
||||
await act(async () => {
|
||||
userEvent.type(formSections[0], 'email@email.com');
|
||||
userEvent.type(formSections[1], 'test name');
|
||||
userEvent.type(formSections[2], 'feedback message');
|
||||
});
|
||||
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||
});
|
||||
|
||||
it('adds validation checking on each input field', async () => {
|
||||
await act(async () => {
|
||||
userEvent.clear(formSections[0]);
|
||||
userEvent.clear(formSections[1]);
|
||||
userEvent.clear(formSections[2]);
|
||||
});
|
||||
const emailError = screen.getByTestId('error-feedback-email');
|
||||
expect(emailError).toBeVisible();
|
||||
|
||||
const fullNameError = screen.getByTestId('error-feedback-email');
|
||||
expect(fullNameError).toBeVisible();
|
||||
|
||||
const messageError = screen.getByTestId('error-feedback-message');
|
||||
expect(messageError).toBeVisible();
|
||||
});
|
||||
|
||||
it('sumbit button is disabled when trying to submit with all empty fields', async () => {
|
||||
await act(async () => {
|
||||
userEvent.clear(formSections[0]);
|
||||
userEvent.clear(formSections[1]);
|
||||
userEvent.clear(formSections[2]);
|
||||
userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(submitButton.closest('button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import messages from './messages';
|
||||
|
||||
const useAccessibility = (initialValues, intl) => {
|
||||
const dispatch = useDispatch();
|
||||
const savingStatus = useSelector(state => state.accessibilityPage.savingStatus);
|
||||
const [isFormFilled, setFormFilled] = useState(false);
|
||||
const validationSchema = Yup.object().shape({
|
||||
name: Yup.string().required(
|
||||
intl.formatMessage(messages.accessibilityPolicyFormValidName),
|
||||
),
|
||||
email: Yup.string()
|
||||
.email(intl.formatMessage(messages.accessibilityPolicyFormValidEmail))
|
||||
.required(intl.formatMessage(messages.accessibilityPolicyFormValidEmail)),
|
||||
message: Yup.string().required(
|
||||
intl.formatMessage(messages.accessibilityPolicyFormValidMessage),
|
||||
),
|
||||
});
|
||||
|
||||
const {
|
||||
values, errors, touched, handleChange, handleBlur, handleReset,
|
||||
} = useFormik({
|
||||
initialValues,
|
||||
enableReinitialize: true,
|
||||
validateOnBlur: false,
|
||||
validationSchema,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setFormFilled(Object.values(values).every((i) => i));
|
||||
}, [values]);
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
handleReset();
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
const hasErrorField = (fieldName) => !!errors[fieldName] && !!touched[fieldName];
|
||||
|
||||
return {
|
||||
errors,
|
||||
values,
|
||||
isFormFilled,
|
||||
dispatch,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
hasErrorField,
|
||||
savingStatus,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAccessibility;
|
||||
@@ -1,3 +0,0 @@
|
||||
import AccessibilityForm from './AccessibilityForm';
|
||||
|
||||
export default AccessibilityForm;
|
||||
@@ -1,76 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
accessibilityPolicyFormEmailLabel: {
|
||||
id: 'accessibilityPolicyFormEmailLabel',
|
||||
defaultMessage: 'Email Address',
|
||||
description: 'Label for the email form field',
|
||||
},
|
||||
accessibilityPolicyFormErrorHighVolume: {
|
||||
id: 'accessibilityPolicyFormErrorHighVolume',
|
||||
defaultMessage: 'We are currently experiencing high volume. Try again later today or send an email message to {emailLink}.',
|
||||
description: 'Error message when site is experiencing high volume that will include an email link',
|
||||
},
|
||||
accessibilityPolicyFormErrorMissingFields: {
|
||||
id: 'accessibilityPolicyFormErrorMissingFields',
|
||||
defaultMessage: 'Make sure to fill in all fields.',
|
||||
description: 'Error message to instruct user to fill in all fields',
|
||||
},
|
||||
accessibilityPolicyFormHeader: {
|
||||
id: 'accessibilityPolicyFormHeader',
|
||||
defaultMessage: 'Studio Accessibility Feedback',
|
||||
description: 'The heading for the form',
|
||||
},
|
||||
accessibilityPolicyFormMessageLabel: {
|
||||
id: 'accessibilityPolicyFormMessageLabel',
|
||||
defaultMessage: 'Message',
|
||||
description: 'Label for the message form field',
|
||||
},
|
||||
accessibilityPolicyFormNameLabel: {
|
||||
id: 'accessibilityPolicyFormNameLabel',
|
||||
defaultMessage: 'Name',
|
||||
description: 'Label for the name form field',
|
||||
},
|
||||
accessibilityPolicyFormSubmitAria: {
|
||||
id: 'accessibilityPolicyFormSubmitAria',
|
||||
defaultMessage: 'Submit Accessibility Feedback Form',
|
||||
description: 'Detailed aria-label for the submit button',
|
||||
},
|
||||
accessibilityPolicyFormSubmitLabel: {
|
||||
id: 'accessibilityPolicyFormSubmitLabel',
|
||||
defaultMessage: 'Submit',
|
||||
description: 'General label for the submit button',
|
||||
},
|
||||
accessibilityPolicyFormSubmittingFeedbackLabel: {
|
||||
id: 'accessibilityPolicyFormSubmittingFeedbackLabel',
|
||||
defaultMessage: 'Submitting',
|
||||
description: 'Loading message while form feedback is being submitted',
|
||||
},
|
||||
accessibilityPolicyFormSuccess: {
|
||||
id: 'accessibilityPolicyFormSuccess',
|
||||
defaultMessage: 'Thank you for contacting edX!',
|
||||
description: 'Simple thank you message when form submission is successful',
|
||||
},
|
||||
accessibilityPolicyFormSuccessDetails: {
|
||||
id: 'accessibilityPolicyFormSuccessDetails',
|
||||
defaultMessage: 'Thank you for your feedback regarding the accessibility of Studio. We typically respond within one business day ({day_start} to {day_end}, {time_start} to {time_end}).',
|
||||
description: 'Detailed thank you message when form submission is successful',
|
||||
},
|
||||
accessibilityPolicyFormValidEmail: {
|
||||
id: 'accessibilityPolicyFormValidEmail',
|
||||
defaultMessage: 'Enter a valid email address.',
|
||||
description: 'Error message for when an invalid email is entered into the form',
|
||||
},
|
||||
accessibilityPolicyFormValidMessage: {
|
||||
id: 'accessibilityPolicyFormValidMessage',
|
||||
defaultMessage: 'Enter a message.',
|
||||
description: 'Error message an invalid message is entered into the form',
|
||||
},
|
||||
accessibilityPolicyFormValidName: {
|
||||
id: 'accessibilityPolicyFormValidName',
|
||||
defaultMessage: 'Enter a name.',
|
||||
description: 'Error message an invalid name is entered into the form',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,42 +0,0 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Container } from '@openedx/paragon';
|
||||
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
|
||||
|
||||
import Header from '../header';
|
||||
import messages from './messages';
|
||||
import AccessibilityBody from './AccessibilityBody';
|
||||
import AccessibilityForm from './AccessibilityForm';
|
||||
|
||||
const AccessibilityPage = ({
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const communityAccessibilityLink = 'https://www.edx.org/accessibility';
|
||||
const email = 'accessibility@edx.org';
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{intl.formatMessage(messages.pageTitle, {
|
||||
siteName: process.env.SITE_NAME,
|
||||
})}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Header isHiddenMainMenu />
|
||||
<Container size="xl" classNamae="px-4">
|
||||
<AccessibilityBody {...{ email, communityAccessibilityLink }} />
|
||||
<AccessibilityForm accessibilityEmail={email} />
|
||||
</Container>
|
||||
<StudioFooterSlot />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AccessibilityPage.propTypes = {
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccessibilityPage);
|
||||
@@ -1,46 +0,0 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import initializeStore from '../store';
|
||||
import AccessibilityPage from './index';
|
||||
|
||||
const initialState = {
|
||||
accessibilityPage: {
|
||||
status: {},
|
||||
},
|
||||
};
|
||||
let store;
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AccessibilityPage />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AccessibilityPolicyPage />', () => {
|
||||
describe('renders', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
});
|
||||
it('contains the policy body', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText('Individualized Accessibility Process for Course Creators')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
ensureConfig([
|
||||
'STUDIO_BASE_URL',
|
||||
], 'Course Apps API service');
|
||||
|
||||
export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getZendeskrUrl = () => `${getApiBaseUrl()}/zendesk_proxy/v0`;
|
||||
|
||||
/**
|
||||
* Posts the form data to zendesk endpoint
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<[{}]>}
|
||||
*/
|
||||
export async function postAccessibilityForm({ name, email, message }) {
|
||||
const data = {
|
||||
name,
|
||||
tags: ['studio_a11y'],
|
||||
email: {
|
||||
from: email,
|
||||
subject: 'Studio Accessibility Request',
|
||||
message,
|
||||
},
|
||||
};
|
||||
|
||||
await getAuthenticatedHttpClient().post(getZendeskrUrl(), data);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'accessibilityPage',
|
||||
initialState: {
|
||||
savingStatus: '',
|
||||
},
|
||||
reducers: {
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { postAccessibilityForm } from './api';
|
||||
import { updateSavingStatus } from './slice';
|
||||
|
||||
function submitAccessibilityForm({ email, name, message }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await postAccessibilityForm({ email, name, message });
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 429) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
} else {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default submitAccessibilityForm;
|
||||
@@ -1,3 +0,0 @@
|
||||
import AccessibilityPage from './AccessibilityPage';
|
||||
|
||||
export default AccessibilityPage;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
pageTitle: {
|
||||
id: 'course-authoring.accessibility.page.title',
|
||||
defaultMessage: 'Studio Accessibility Policy| {siteName}',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -3,10 +3,10 @@ import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Container, Button, Layout, StatefulButton, TransitionReplace,
|
||||
} from '@openedx/paragon';
|
||||
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
|
||||
} from '@edx/paragon';
|
||||
import { CheckCircle, Info, Warning } from '@edx/paragon/icons';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import Placeholder from '../editors/Placeholder';
|
||||
import Placeholder from '@edx/frontend-lib-content-components';
|
||||
|
||||
import AlertProctoringError from '../generic/AlertProctoringError';
|
||||
import { useModel } from '../generic/model-store';
|
||||
@@ -25,6 +25,9 @@ import validateAdvancedSettingsData from './utils';
|
||||
import messages from './messages';
|
||||
import ModalError from './modal-error/ModalError';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { useUserPermissions } from '../generic/hooks';
|
||||
import { getUserPermissionsEnabled } from '../generic/data/selectors';
|
||||
import PermissionDeniedAlert from '../generic/PermissionDeniedAlert';
|
||||
|
||||
const AdvancedSettings = ({ intl, courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -41,6 +44,13 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
|
||||
|
||||
const { checkPermission } = useUserPermissions();
|
||||
const userPermissionsEnabled = useSelector(getUserPermissionsEnabled);
|
||||
const viewOnly = checkPermission('view_course_settings');
|
||||
const showPermissionDeniedAlert = userPermissionsEnabled && (
|
||||
!checkPermission('manage_advanced_settings') && !checkPermission('view_course_settings')
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseAppSettings(courseId));
|
||||
dispatch(fetchProctoringExamErrors(courseId));
|
||||
@@ -83,6 +93,11 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
}
|
||||
if (showPermissionDeniedAlert) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
if (loadingSettingsStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<div className="row justify-content-center m-6">
|
||||
@@ -215,6 +230,7 @@ const AdvancedSettings = ({ intl, courseId }) => {
|
||||
handleBlur={handleSettingBlur}
|
||||
isEditableState={isEditableState}
|
||||
setIsEditableState={setIsEditableState}
|
||||
disableForm={viewOnly}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import initializeStore from '../store';
|
||||
@@ -13,11 +17,15 @@ import { getCourseAdvancedSettingsApiUrl } from './data/api';
|
||||
import { updateCourseAppSetting } from './data/thunks';
|
||||
import AdvancedSettings from './AdvancedSettings';
|
||||
import messages from './messages';
|
||||
import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api';
|
||||
import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
const userId = 3;
|
||||
const userPermissionsData = { permissions: ['view_course_settings', 'manage_advanced_settings'] };
|
||||
|
||||
// Mock the TextareaAutosize component
|
||||
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
@@ -43,11 +51,23 @@ const RootWrapper = () => (
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
const permissionsMockStore = async (permissions) => {
|
||||
axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, permissions);
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true });
|
||||
await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
const permissionDisabledMockStore = async () => {
|
||||
axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false });
|
||||
await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch);
|
||||
};
|
||||
|
||||
describe('<AdvancedSettings />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
userId,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
@@ -58,7 +78,9 @@ describe('<AdvancedSettings />', () => {
|
||||
axiosMock
|
||||
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
|
||||
.reply(200, advancedSettingsMock);
|
||||
permissionsMockStore(userPermissionsData);
|
||||
});
|
||||
|
||||
it('should render without errors', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
@@ -161,4 +183,29 @@ describe('<AdvancedSettings />', () => {
|
||||
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
|
||||
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
|
||||
});
|
||||
it('should shows the PermissionDeniedAlert when there are not the right user permissions', async () => {
|
||||
const permissionsData = { permissions: ['view'] };
|
||||
await permissionsMockStore(permissionsData);
|
||||
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
|
||||
expect(permissionDeniedAlert).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should not show the PermissionDeniedAlert when the User Permissions Flag is not enabled', async () => {
|
||||
await permissionDisabledMockStore();
|
||||
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.');
|
||||
expect(permissionDeniedAlert).not.toBeInTheDocument();
|
||||
});
|
||||
it('should be view only if the permission is set for viewOnly', async () => {
|
||||
const permissions = { permissions: ['view_course_settings'] };
|
||||
await permissionsMockStore(permissions);
|
||||
const { getByLabelText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
expect(getByLabelText('Advanced Module List')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as advancedSettingsMock } from './advancedSettings';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { convertObjectToSnakeCase } from '../../utils';
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as AdvancedSettings } from './AdvancedSettings';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ActionRow, AlertModal, Button } from '@openedx/paragon';
|
||||
import { ActionRow, AlertModal, Button } from '@edx/paragon';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ModalErrorListItem from './ModalErrorListItem';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Icon } from '@openedx/paragon';
|
||||
import { Error } from '@openedx/paragon/icons';
|
||||
import { Alert, Icon } from '@edx/paragon';
|
||||
import { Error } from '@edx/paragon/icons';
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
import { transformKeysToCamelCase } from '../../utils';
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
IconButton,
|
||||
ModalPopup,
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { InfoOutline, Warning } from '@openedx/paragon/icons';
|
||||
} from '@edx/paragon';
|
||||
import { InfoOutline, Warning } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { capitalize } from 'lodash';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -27,6 +27,7 @@ const SettingCard = ({
|
||||
setIsEditableState,
|
||||
// injected
|
||||
intl,
|
||||
disableForm,
|
||||
}) => {
|
||||
const { deprecated, help, displayName } = settingData;
|
||||
const initialValue = JSON.stringify(settingData.value, null, 4);
|
||||
@@ -71,7 +72,7 @@ const SettingCard = ({
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.helpButtonText)}
|
||||
variant="primary"
|
||||
className="flex-shrink-0 ml-1 mr-2"
|
||||
className=" ml-1 mr-2"
|
||||
/>
|
||||
<ModalPopup
|
||||
hasArrow
|
||||
@@ -100,6 +101,7 @@ const SettingCard = ({
|
||||
onChange={handleSettingChange}
|
||||
aria-label={displayName}
|
||||
onBlur={handleCardBlur}
|
||||
disabled={disableForm}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Card.Section>
|
||||
@@ -135,6 +137,7 @@ SettingCard.propTypes = {
|
||||
saveSettingsPrompt: PropTypes.bool.isRequired,
|
||||
isEditableState: PropTypes.bool.isRequired,
|
||||
setIsEditableState: PropTypes.func.isRequired,
|
||||
disableForm: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SettingCard);
|
||||
|
||||
@@ -79,7 +79,3 @@
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
|
||||
.react-datepicker-popper {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,3 @@
|
||||
.mw-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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user