Compare commits
2 Commits
release/ul
...
abdullahwa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb5f51e47 | ||
|
|
6a115797e6 |
13
.env
13
.env
@@ -1,4 +1,3 @@
|
||||
APP_ID='authoring'
|
||||
NODE_ENV='production'
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
BASE_URL=''
|
||||
@@ -31,21 +30,15 @@ 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=''
|
||||
AI_TRANSLATIONS_BASE_URL=''
|
||||
ENABLE_CHECKLIST_QUALITY=''
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
COURSE_TEAM_SUPPORT_EMAIL=''
|
||||
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'
|
||||
|
||||
@@ -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,22 +32,15 @@ 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"
|
||||
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'
|
||||
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"
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
COURSE_TEAM_SUPPORT_EMAIL=''
|
||||
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'
|
||||
|
||||
@@ -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,11 @@ 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_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
PARAGON_THEME_URLS=
|
||||
COURSE_TEAM_SUPPORT_EMAIL='support@example.com'
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
coverage/*
|
||||
dist/
|
||||
node_modules/
|
||||
jest.config.js
|
||||
env.config.jsx
|
||||
example.env.config.jsx
|
||||
jest.config.js
|
||||
@@ -11,9 +11,8 @@ module.exports = createConfig(
|
||||
}],
|
||||
'template-curly-spacing': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
indent: ['error', 2],
|
||||
'no-restricted-exports': 'off',
|
||||
// There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers
|
||||
'no-restricted-syntax': 'off',
|
||||
},
|
||||
settings: {
|
||||
// Import URLs should be resolved using aliases
|
||||
|
||||
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"
|
||||
25
.github/pull_request_template.md
vendored
25
.github/pull_request_template.md
vendored
@@ -2,37 +2,26 @@
|
||||
|
||||
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
|
||||
[OEP-19](https://open-edx-proposals.readthedocs.io/en/latest/oep-0019-bp-developer-documentation.html), and can be linked here.
|
||||
|
||||
Useful information to include:
|
||||
- Which user roles will this change impact? Common user roles are "Learner", "Course Author",
|
||||
- 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 GitHub issues, or Discourse discussions.
|
||||
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 manually testing this change.
|
||||
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.
|
||||
|
||||
## Best Practices Checklist
|
||||
|
||||
We're trying to move away from some deprecated patterns in this codebase. Please
|
||||
check if your PR meets these recommendations before asking for a review:
|
||||
|
||||
- [ ] Any _new_ files are using TypeScript (`.ts`, `.tsx`).
|
||||
- [ ] Deprecated `propTypes`, `defaultProps`, and `injectIntl` patterns are not used in any new or modified code.
|
||||
- [ ] Tests should use the helpers in `src/testUtils.tsx` (specifically `initializeMocks`)
|
||||
- [ ] Do not add new fields to the Redux state/store. Use React Context to share state among multiple components.
|
||||
- [ ] Use React Query to load data from REST APIs. See any `apiHooks.ts` in this repo for examples.
|
||||
- [ ] All new i18n messages in `messages.ts` files have a `description` for translators to use.
|
||||
- [ ] Imports avoid using `../`. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`
|
||||
- Any special concerns or limitations? For example: deprecations, migrations, security, or accessibility.
|
||||
18
.github/workflows/add-issue-to-btr-project.yml
vendored
18
.github/workflows/add-issue-to-btr-project.yml
vendored
@@ -1,18 +0,0 @@
|
||||
# Run the workflow that adds new tickets that are labelled "release testing"
|
||||
# to the org-wide BTR project board
|
||||
|
||||
name: Add release testing issues to the BTR project board
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
# This workflow is triggered when an issue is labeled with 'release testing'.
|
||||
# It adds the issue to the BTR project and applies the 'needs triage' label
|
||||
# if it doesn't already have it.
|
||||
|
||||
jobs:
|
||||
handle-release-testing:
|
||||
uses: openedx/.github/.github/workflows/add-issue-to-btr-project.yml@master
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
15
.github/workflows/add-to-cc-board.yml
vendored
15
.github/workflows/add-to-cc-board.yml
vendored
@@ -1,15 +0,0 @@
|
||||
name: Trigger to add Issue or PR to a Core Contributor project board
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
add-to-cc-board:
|
||||
if: github.event.label.name == 'Core Contributor assignee'
|
||||
uses: openedx/.github/.github/workflows/add-to-cc-board.yml@master
|
||||
with:
|
||||
board_name: cc-frontend-apps
|
||||
secrets:
|
||||
projects_access_token: ${{ secrets.PROJECTS_TOKEN }}
|
||||
28
.github/workflows/validate.yml
vendored
28
.github/workflows/validate.yml
vendored
@@ -9,31 +9,15 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
- 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@v5
|
||||
with:
|
||||
name: code-coverage-report
|
||||
path: coverage/*.*
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
pattern: code-coverage-report
|
||||
path: coverage
|
||||
merge-multiple: true
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,7 +1,6 @@
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
.run
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
@@ -27,6 +26,3 @@ temp/babel-plugin-react-intl
|
||||
|
||||
# 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"]
|
||||
}],
|
||||
|
||||
2
CODEOWNERS
Normal file
2
CODEOWNERS
Normal file
@@ -0,0 +1,2 @@
|
||||
# The following users are the maintainers of all frontend-app-course-authoring files
|
||||
* @openedx/2u-tnl
|
||||
5
Makefile
5
Makefile
@@ -35,12 +35,13 @@ pull_translations:
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull $(ATLAS_OPTIONS) \
|
||||
translations/frontend-component-ai-translations/src/i18n/messages:frontend-component-ai-translations \
|
||||
translations/frontend-lib-content-components/src/i18n/messages:frontend-lib-content-components \
|
||||
translations/frontend-platform/src/i18n/messages:frontend-platform \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring
|
||||
|
||||
$(intl_imports) frontend-component-ai-translations frontend-platform paragon frontend-component-footer frontend-app-course-authoring
|
||||
$(intl_imports) frontend-component-ai-translations frontend-lib-content-components frontend-platform paragon frontend-component-footer frontend-app-course-authoring
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
@@ -53,7 +54,7 @@ validate:
|
||||
npm run i18n_extract
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run types
|
||||
npm run test:ci
|
||||
npm run test
|
||||
npm run build
|
||||
|
||||
.PHONY: validate.ci
|
||||
|
||||
201
README.rst
201
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
|
||||
@@ -165,7 +129,32 @@ Feature: New React XBlock Editors
|
||||
|
||||
.. image:: ./docs/readme-images/feature-problem-editor.png
|
||||
|
||||
New React editors for the HTML, Video, and Problem XBlocks are provided here and are rendered by this MFE instead of by the XBlock's authoring view.
|
||||
This allows an operator to enable the use of new React editors for the HTML, Video, and Problem XBlocks, all of which are provided here.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``new_core_editors.use_new_text_editor``: must be enabled for the new HTML Xblock editor to be used in Studio
|
||||
* ``new_core_editors.use_new_video_editor``: must be enabled for the new Video Xblock editor to be used in Studio
|
||||
* ``new_core_editors.use_new_problem_editor``: must be enabled for the new Problem Xblock editor to be used in Studio
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
In additional to the standard settings, the following local configuration item is required:
|
||||
|
||||
* ``ENABLE_NEW_EDITOR_PAGES``: must be enabled in order to actually present the new XBlock editors (on by default)
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
When a corresponding waffle flag is set, upon editing a block in Studio, the view is rendered by this MFE instead of by the XBlock's authoring view. The user remains in Studio.
|
||||
|
||||
.. note::
|
||||
|
||||
The new editors themselves are currently implemented in a repository outside ``openedx``: `frontend-lib-content-components <https://github.com/edx/frontend-lib-content-components/>`_, a dependency of this MFE. This repository is slated to be moved to the ``openedx`` org, however.
|
||||
|
||||
Feature: New Proctoring Exams View
|
||||
==================================
|
||||
@@ -179,6 +168,10 @@ Requirements
|
||||
|
||||
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
|
||||
|
||||
* ``edx-platform`` Feature flags:
|
||||
|
||||
* ``ENABLE_EXAM_SETTINGS_HTML_VIEW``: this feature flag must be enabled for the link to the settings view to be shown
|
||||
|
||||
* `edx-exams <https://github.com/edx/edx-exams>`_: for this feature to work, the ``edx-exams`` IDA must be deployed and its API accessible by the browser
|
||||
|
||||
Configuration
|
||||
@@ -203,6 +196,16 @@ Feature: Advanced Settings
|
||||
|
||||
.. image:: ./docs/readme-images/feature-advanced-settings.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_advanced_settings_page``: this feature flag must be enabled for the link to the settings view to be shown. It can be enabled on a per-course basis.
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
In Studio, the "Advanced Settings" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. The advanced settings page holds many different settings for the course, such as what features or XBlocks are enabled.
|
||||
|
||||
Feature: Files & Uploads
|
||||
@@ -210,6 +213,16 @@ Feature: Files & Uploads
|
||||
|
||||
.. image:: ./docs/readme-images/feature-files-uploads.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_files_uploads_page``: this feature flag must be enabled for the link to the Files & Uploads page to go to the MFE. It can be enabled on a per-course basis.
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
In Studio, the "Files & Uploads" page for each enabled course will now be served by this frontend, instead of the UI built into edx-platform. This page allows managing static asset files like PDFs, images, etc. used for the course.
|
||||
|
||||
Feature: Course Updates
|
||||
@@ -217,11 +230,26 @@ Feature: Course Updates
|
||||
|
||||
.. image:: ./docs/readme-images/feature-course-updates.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_updates_page``: this feature flag must be enabled.
|
||||
|
||||
Feature: Import/Export Pages
|
||||
============================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-export.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.use_new_export_page``: this feature flag will change the CMS to link to the new export page.
|
||||
* ``contentstore.new_studio_mfe.use_new_import_page``: this feature flag will change the CMS to link to the new import page.
|
||||
|
||||
Feature: Tagging/Taxonomy Pages
|
||||
================================
|
||||
|
||||
@@ -239,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:
|
||||
@@ -291,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
|
||||
====================
|
||||
@@ -327,20 +338,6 @@ For more information about these options, see the `Getting Help`_ page.
|
||||
.. _Getting Help: https://openedx.org/community/connect
|
||||
|
||||
|
||||
Legacy Studio
|
||||
*************
|
||||
|
||||
If you would like to use legacy studio for certain features, you can set the following waffle flags in ``edx-platform``:
|
||||
* ``legacy_studio.text_editor``: loads the legacy HTML Xblock editor when editing a text block
|
||||
* ``legacy_studio.video_editor``: loads the legacy Video editor when editing a video block
|
||||
* ``legacy_studio.problem_editor``: loads the legacy Problem editor when editing a problem block
|
||||
* ``legacy_studio.advanced_settings``: Advanced Settings page
|
||||
* ``legacy_studio.updates``: Updates page
|
||||
* ``legacy_studio.export``: Export page
|
||||
* ``legacy_studio.import``: Import page
|
||||
* ``legacy_studio.files_uploads``: Files page
|
||||
* ``legacy_studio.exam_settings``: loads the legacy Exam Settings
|
||||
|
||||
License
|
||||
*******
|
||||
|
||||
|
||||
@@ -4,16 +4,15 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: 'frontend-app-authoring'
|
||||
description: "The frontend (MFE) for Open edX Authoring (aka Studio)"
|
||||
name: 'frontend-app-course-authoring'
|
||||
description: "The frontend (MFE) for Open edX Course Authoring (aka Studio)"
|
||||
links:
|
||||
- url: "https://github.com/openedx/frontend-app-authoring"
|
||||
title: "Frontend app authoring"
|
||||
- url: "https://github.com/openedx/frontend-app-course-authoring"
|
||||
title: "Frontend app course authoring"
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
openedx.org/release: "master"
|
||||
spec:
|
||||
owner: user:bradenmacdonald
|
||||
owner: group:2u-tnl
|
||||
type: 'website'
|
||||
lifecycle: 'production'
|
||||
|
||||
@@ -10,6 +10,4 @@ coverage:
|
||||
threshold: 0%
|
||||
ignore:
|
||||
- "src/grading-settings/grading-scale/react-ranger.js"
|
||||
- "src/generic/DraggableList/verticalSortableList.ts"
|
||||
- "src/container-comparison/data/api.mock.ts"
|
||||
- "src/index.js"
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import WholeCourseTranslation from '@edx/course-app-translation-plugin';
|
||||
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
// Load environment variables from .env file
|
||||
const config = {
|
||||
...process.env,
|
||||
pluginSlots: {
|
||||
additional_course_plugin: {
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'whole-course-translation-plugin',
|
||||
type: DIRECT_PLUGIN,
|
||||
priority: 1,
|
||||
RenderWidget: WholeCourseTranslation,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -11,11 +11,9 @@ module.exports = createConfig('jest', {
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^lodash-es$': 'lodash',
|
||||
// This alias is for any code in the src directory that wants to avoid '../../' style relative imports:
|
||||
'^@src/(.*)$': '<rootDir>/src/$1',
|
||||
// This alias is used for plugins in the plugins/ folder only.
|
||||
'^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
|
||||
24395
package-lock.json
generated
24395
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
132
package.json
132
package.json
@@ -1,54 +1,59 @@
|
||||
{
|
||||
"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"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "fedx-scripts formatjs extract --include=plugins",
|
||||
"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 .",
|
||||
"i18n_extract": "fedx-scripts formatjs extract",
|
||||
"stylelint": "stylelint \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
|
||||
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
|
||||
"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-markdown": "^6.0.0",
|
||||
"@codemirror/lang-xml": "^6.0.0",
|
||||
"@codemirror/lint": "^6.2.1",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@datadog/browser-rum": "^5.13.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.5.0",
|
||||
"@edx/frontend-component-footer": "^14.9.0",
|
||||
"@edx/frontend-component-header": "^8.1.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^7.2.0",
|
||||
"@edx/frontend-platform": "^8.4.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-ai-translations": "^2.0.0",
|
||||
"@edx/frontend-component-footer": "^13.0.2",
|
||||
"@edx/frontend-component-header": "^5.0.2",
|
||||
"@edx/frontend-enterprise-hotjar": "^2.0.0",
|
||||
"@edx/frontend-lib-content-components": "^2.1.4",
|
||||
"@edx/frontend-platform": "7.0.1",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
||||
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||
@@ -59,64 +64,59 @@
|
||||
"@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.6.2",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^23.5.0",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@openedx/paragon": "^21.5.7",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tanstack/react-query": "5.90.5",
|
||||
"@tinymce/tinymce-react": "^6.0.0",
|
||||
"classnames": "2.5.1",
|
||||
"codemirror": "^6.0.0",
|
||||
"@tanstack/react-query": "4.36.1",
|
||||
"broadcast-channel": "^7.0.0",
|
||||
"classnames": "2.2.6",
|
||||
"core-js": "3.8.1",
|
||||
"email-validator": "2.0.4",
|
||||
"fast-xml-parser": "^5.0.0",
|
||||
"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",
|
||||
"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.30.1",
|
||||
"react-router-dom": "6.30.1",
|
||||
"react-select": "5.10.2",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-router": "6.16.0",
|
||||
"react-router-dom": "6.16.0",
|
||||
"react-select": "5.8.0",
|
||||
"react-textarea-autosize": "^8.4.1",
|
||||
"react-transition-group": "4.4.5",
|
||||
"redux": "4.2.1",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.4.1",
|
||||
"reselect": "^4.1.5",
|
||||
"tinymce": "^5.10.4",
|
||||
"universal-cookie": "^8.0.0",
|
||||
"uuid": "^11.1.0",
|
||||
"xmlchecker": "^0.1.0",
|
||||
"yup": "0.32.11"
|
||||
"redux": "4.0.5",
|
||||
"regenerator-runtime": "0.13.7",
|
||||
"universal-cookie": "^4.0.4",
|
||||
"uuid": "^3.4.0",
|
||||
"yup": "0.31.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/stylelint-config-edx": "2.3.3",
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/react-unit-test-utils": "^2.0.0",
|
||||
"@edx/reactifex": "^1.0.3",
|
||||
"@edx/stylelint-config-edx": "2.3.0",
|
||||
"@edx/typescript-config": "^1.0.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"axios-mock-adapter": "2.1.0",
|
||||
"@openedx/frontend-build": "13.0.27",
|
||||
"@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",
|
||||
"axios": "^0.27.2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Calculator configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"version": "0.1.0",
|
||||
"description": "edxnotes configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@ import React from 'react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import { initializeMocks, render } from 'CourseAuthoring/testUtils';
|
||||
import { render } from 'CourseAuthoring/pages-and-resources/utils.test';
|
||||
import LearningAssistantSettings from './Settings';
|
||||
|
||||
const onClose = () => { };
|
||||
|
||||
describe('Learning Assistant Settings', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders', async () => {
|
||||
const initialState = {
|
||||
models: {
|
||||
@@ -34,8 +38,14 @@ describe('Learning Assistant Settings', () => {
|
||||
},
|
||||
};
|
||||
|
||||
initializeMocks({ initialState });
|
||||
render(<LearningAssistantSettings onClose={onClose} />);
|
||||
render(
|
||||
<LearningAssistantSettings
|
||||
onClose={onClose}
|
||||
/>,
|
||||
{
|
||||
preloadedState: initialState,
|
||||
},
|
||||
);
|
||||
|
||||
const toggleDescription = 'Reinforce learning concepts by sharing text-based course content '
|
||||
+ 'with OpenAI (via API) to power an in-course Learning Assistant. Learners can leave feedback about the quality '
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Learning Assistant configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
@@ -11,7 +11,7 @@
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Form, Hyperlink } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
|
||||
@@ -11,10 +11,10 @@ import LiveCommonFields from './LiveCommonFields';
|
||||
import messages from './messages';
|
||||
|
||||
const BbbSettings = ({
|
||||
intl,
|
||||
values,
|
||||
setFieldValue,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [bbbPlan, setBbbPlan] = useState(values.tierType);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -107,10 +107,12 @@ const BbbSettings = ({
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
BbbSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
values: PropTypes.shape({
|
||||
consumerKey: PropTypes.string,
|
||||
consumerSecret: PropTypes.string,
|
||||
@@ -125,4 +127,4 @@ BbbSettings.propTypes = {
|
||||
setFieldValue: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default BbbSettings;
|
||||
export default injectIntl(BbbSettings);
|
||||
|
||||
@@ -124,13 +124,12 @@ describe('BBB Settings', () => {
|
||||
);
|
||||
|
||||
test('free plans message is visible when free plan is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
await mockStore({ emailSharing: true, isFreeTier: true });
|
||||
renderComponent();
|
||||
const spinner = getByRole(container, 'status');
|
||||
await waitForElementToBeRemoved(spinner);
|
||||
const dropDown = container.querySelector('select[name="tierType"]');
|
||||
await user.selectOptions(
|
||||
userEvent.selectOptions(
|
||||
dropDown,
|
||||
getByRole(dropDown, 'option', { name: 'Free' }),
|
||||
);
|
||||
|
||||
@@ -1,43 +1,42 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const LiveCommonFields = ({
|
||||
intl,
|
||||
values,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<>
|
||||
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
|
||||
<FormikControl
|
||||
name="consumerKey"
|
||||
value={values.consumerKey}
|
||||
floatingLabel={intl.formatMessage(messages.consumerKey)}
|
||||
className="pb-1"
|
||||
type="input"
|
||||
/>
|
||||
<FormikControl
|
||||
name="consumerSecret"
|
||||
value={values.consumerSecret}
|
||||
floatingLabel={intl.formatMessage(messages.consumerSecret)}
|
||||
className="pb-1"
|
||||
type="password"
|
||||
/>
|
||||
<FormikControl
|
||||
name="launchUrl"
|
||||
value={values.launchUrl}
|
||||
floatingLabel={intl.formatMessage(messages.launchUrl)}
|
||||
className="pb-1"
|
||||
type="input"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}) => (
|
||||
<>
|
||||
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
|
||||
<FormikControl
|
||||
name="consumerKey"
|
||||
value={values.consumerKey}
|
||||
floatingLabel={intl.formatMessage(messages.consumerKey)}
|
||||
className="pb-1"
|
||||
type="input"
|
||||
/>
|
||||
<FormikControl
|
||||
name="consumerSecret"
|
||||
value={values.consumerSecret}
|
||||
floatingLabel={intl.formatMessage(messages.consumerSecret)}
|
||||
className="pb-1"
|
||||
type="password"
|
||||
/>
|
||||
<FormikControl
|
||||
name="launchUrl"
|
||||
value={values.launchUrl}
|
||||
floatingLabel={intl.formatMessage(messages.launchUrl)}
|
||||
className="pb-1"
|
||||
type="input"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
LiveCommonFields.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
values: PropTypes.shape({
|
||||
consumerKey: PropTypes.string,
|
||||
consumerSecret: PropTypes.string,
|
||||
@@ -46,4 +45,4 @@ LiveCommonFields.propTypes = {
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default LiveCommonFields;
|
||||
export default injectIntl(LiveCommonFields);
|
||||
|
||||
@@ -2,11 +2,11 @@ import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { camelCase } from 'lodash';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { SelectableBox } from '@edx/frontend-lib-content-components';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as Yup from 'yup';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import SelectableBox from 'CourseAuthoring/editors/sharedComponents/SelectableBox';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
import Loading from 'CourseAuthoring/generic/Loading';
|
||||
@@ -20,9 +20,9 @@ import ZoomSettings from './ZoomSettings';
|
||||
import BBBSettings from './BBBSettings';
|
||||
|
||||
const LiveSettings = ({
|
||||
intl,
|
||||
onClose,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const courseId = useSelector(state => state.courseDetail.courseId);
|
||||
@@ -130,7 +130,8 @@ const LiveSettings = ({
|
||||
};
|
||||
|
||||
LiveSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default LiveSettings;
|
||||
export default injectIntl(LiveSettings);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||
|
||||
@@ -8,38 +8,37 @@ import { providerNames } from './constants';
|
||||
import LiveCommonFields from './LiveCommonFields';
|
||||
|
||||
const ZoomSettings = ({
|
||||
intl,
|
||||
values,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
<>
|
||||
{!values.piiSharingEnable ? (
|
||||
<p data-testid="request-pii-sharing">
|
||||
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{(values.piiSharingEmail || values.piiSharingUsername)
|
||||
&& (
|
||||
<p data-testid="helper-text">
|
||||
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
|
||||
</p>
|
||||
)}
|
||||
<LiveCommonFields values={values} />
|
||||
<FormikControl
|
||||
name="launchEmail"
|
||||
value={values.launchEmail}
|
||||
floatingLabel={intl.formatMessage(messages.launchEmail)}
|
||||
type="input"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}) => (
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
<>
|
||||
{!values.piiSharingEnable ? (
|
||||
<p data-testid="request-pii-sharing">
|
||||
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{(values.piiSharingEmail || values.piiSharingUsername)
|
||||
&& (
|
||||
<p data-testid="helper-text">
|
||||
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
|
||||
</p>
|
||||
)}
|
||||
<LiveCommonFields values={values} />
|
||||
<FormikControl
|
||||
name="launchEmail"
|
||||
value={values.launchEmail}
|
||||
floatingLabel={intl.formatMessage(messages.launchEmail)}
|
||||
type="input"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
ZoomSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
values: PropTypes.shape({
|
||||
consumerKey: PropTypes.string,
|
||||
consumerSecret: PropTypes.string,
|
||||
@@ -52,4 +51,4 @@ ZoomSettings.propTypes = {
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default ZoomSettings;
|
||||
export default injectIntl(ZoomSettings);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { bbbPlanTypes } from '../constants';
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Live course configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-lib-content-components": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"@reduxjs/toolkit": "*",
|
||||
@@ -15,7 +16,7 @@
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,176 +1,69 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import {
|
||||
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
import { Info } from '@openedx/paragon/icons';
|
||||
import { updateModel, useModel } from 'CourseAuthoring/generic/model-store';
|
||||
import { Hyperlink } from '@openedx/paragon';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||
import Loading from 'CourseAuthoring/generic/Loading';
|
||||
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
||||
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
|
||||
import { useAppSetting, useIsMobile } from 'CourseAuthoring/utils';
|
||||
import { getLoadingStatus, getSavingStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
|
||||
import { updateSavingStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
|
||||
|
||||
import { useAppSetting } from 'CourseAuthoring/utils';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
const ORASettings = ({ onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { formatMessage } = useIntl();
|
||||
const alertRef = useRef(null);
|
||||
const updateSettingsRequestStatus = useSelector(getSavingStatus);
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
const isMobile = useIsMobile();
|
||||
const modalVariant = isMobile ? 'dark' : 'default';
|
||||
const ORASettings = ({ intl, onClose }) => {
|
||||
const appId = 'ora_settings';
|
||||
const appInfo = useModel('courseApps', appId);
|
||||
|
||||
const [enableFlexiblePeerGrade, saveSetting] = useAppSetting(
|
||||
'forceOnFlexiblePeerOpenassessments',
|
||||
);
|
||||
const initialFormValues = { enableFlexiblePeerGrade };
|
||||
|
||||
const [formValues, setFormValues] = useState(initialFormValues);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
|
||||
const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default';
|
||||
const handleSettingsSave = (values) => saveSetting(values.enableFlexiblePeerGrade);
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
let success = true;
|
||||
event.preventDefault();
|
||||
|
||||
success = success && await handleSettingsSave(formValues);
|
||||
await setSaveError(!success);
|
||||
if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) {
|
||||
success = await dispatch(updateModel({
|
||||
modelType: 'courseApps',
|
||||
model: {
|
||||
id: appId, enabled: formValues.enableFlexiblePeerGrade,
|
||||
},
|
||||
}));
|
||||
}
|
||||
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormValues({ enableFlexiblePeerGrade: e.target.checked });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) {
|
||||
dispatch(updateSavingStatus({ status: '' }));
|
||||
onClose();
|
||||
}
|
||||
}, [updateSettingsRequestStatus]);
|
||||
|
||||
const renderBody = () => {
|
||||
switch (loadingStatus) {
|
||||
case RequestStatus.SUCCESSFUL:
|
||||
return (
|
||||
<>
|
||||
{saveError && (
|
||||
<Alert variant="danger" icon={Info} ref={alertRef}>
|
||||
<Alert.Heading>
|
||||
{formatMessage(messages.errorSavingTitle)}
|
||||
</Alert.Heading>
|
||||
{formatMessage(messages.errorSavingMessage)}
|
||||
</Alert>
|
||||
)}
|
||||
<FormSwitchGroup
|
||||
id="enable-flexible-peer-grade"
|
||||
name="enableFlexiblePeerGrade"
|
||||
label={(
|
||||
<div className="d-flex align-items-center">
|
||||
{formatMessage(messages.enableFlexPeerGradeLabel)}
|
||||
{formValues.enableFlexiblePeerGrade && (
|
||||
<Badge className="ml-2" variant="success" data-testid="enable-badge">
|
||||
{formatMessage(messages.enabledBadgeLabel)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
helpText={(
|
||||
<div>
|
||||
<p>{formatMessage(messages.enableFlexPeerGradeHelp)}</p>
|
||||
<span className="py-3">
|
||||
<Hyperlink
|
||||
className="text-primary-500 small"
|
||||
destination={appInfo.documentationLinks?.learnMoreConfiguration}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{formatMessage(messages.ORASettingsHelpLink)}
|
||||
</Hyperlink>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
onChange={handleChange}
|
||||
checked={formValues.enableFlexiblePeerGrade}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case RequestStatus.DENIED:
|
||||
return <PermissionDeniedAlert />;
|
||||
case RequestStatus.FAILED:
|
||||
return <ConnectionErrorAlert />;
|
||||
default:
|
||||
return <Loading />;
|
||||
}
|
||||
};
|
||||
const title = (
|
||||
<div>
|
||||
<p>{intl.formatMessage(messages.heading)}</p>
|
||||
<div className="pt-3">
|
||||
<Hyperlink
|
||||
className="text-primary-500 small"
|
||||
destination={appInfo.documentationLinks?.learnMoreConfiguration}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{intl.formatMessage(messages.ORASettingsHelpLink)}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
title={formatMessage(messages.heading)}
|
||||
isOpen
|
||||
<AppSettingsModal
|
||||
appId={appId}
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
variant={modalVariant}
|
||||
hasCloseButton={isMobile}
|
||||
isFullscreenScroll
|
||||
isFullscreenOnMobile
|
||||
initialValues={{ enableFlexiblePeerGrade }}
|
||||
validationSchema={{ enableFlexiblePeerGrade: Yup.boolean() }}
|
||||
onSettingsSave={handleSettingsSave}
|
||||
hideAppToggle
|
||||
>
|
||||
<Form onSubmit={handleSubmit} data-testid="proctoringForm">
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
{formatMessage(messages.heading)}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
{renderBody()}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer className="p-4">
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{formatMessage(messages.cancelLabel)}
|
||||
</ModalDialog.CloseButton>
|
||||
<StatefulButton
|
||||
labels={{
|
||||
default: formatMessage(messages.saveLabel),
|
||||
pending: formatMessage(messages.pendingSaveLabel),
|
||||
}}
|
||||
description="Form save button"
|
||||
data-testid="submissionButton"
|
||||
disabled={submitButtonState === RequestStatus.IN_PROGRESS}
|
||||
state={submitButtonState}
|
||||
type="submit"
|
||||
/>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</Form>
|
||||
</ModalDialog>
|
||||
{({ values, handleChange, handleBlur }) => (
|
||||
<FormSwitchGroup
|
||||
id="enable-flexible-peer-grade"
|
||||
name="enableFlexiblePeerGrade"
|
||||
label={intl.formatMessage(messages.enableFlexPeerGradeLabel)}
|
||||
helpText={intl.formatMessage(messages.enableFlexPeerGradeHelp)}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
checked={values.enableFlexiblePeerGrade}
|
||||
/>
|
||||
)}
|
||||
</AppSettingsModal>
|
||||
);
|
||||
};
|
||||
|
||||
ORASettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ORASettings;
|
||||
export default injectIntl(ORASettings);
|
||||
|
||||
@@ -1,155 +1,33 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import { getCourseAppsApiUrl, getCourseAdvancedSettingsApiUrl } from 'CourseAuthoring/pages-and-resources/data/api';
|
||||
import { fetchCourseApps, fetchCourseAppSettings } from 'CourseAuthoring/pages-and-resources/data/thunks';
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
import ORASettings from './Settings';
|
||||
import messages from './messages';
|
||||
import {
|
||||
courseId,
|
||||
inititalState,
|
||||
} from './factories/mockData';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const oraSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform/i18n'), // use actual for all non-hook parts
|
||||
injectIntl: (component) => component,
|
||||
intlShape: {},
|
||||
}));
|
||||
jest.mock('yup', () => ({
|
||||
boolean: jest.fn().mockReturnValue('Yub.boolean'),
|
||||
}));
|
||||
jest.mock('CourseAuthoring/generic/model-store', () => ({
|
||||
useModel: jest.fn().mockReturnValue({ documentationLinks: { learnMoreConfiguration: 'https://learnmore.test' } }),
|
||||
}));
|
||||
jest.mock('CourseAuthoring/generic/FormSwitchGroup', () => 'FormSwitchGroup');
|
||||
jest.mock('CourseAuthoring/utils', () => ({
|
||||
useAppSetting: jest.fn().mockReturnValue(['abitrary value', jest.fn().mockName('saveSetting')]),
|
||||
}));
|
||||
jest.mock('CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal', () => 'AppSettingsModal');
|
||||
|
||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
||||
ReactDOM.createPortal = jest.fn(node => node);
|
||||
|
||||
const renderComponent = () => (
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<PagesAndResourcesProvider courseId={courseId}>
|
||||
<MemoryRouter initialEntries={[oraSettingsUrl]}>
|
||||
<Routes>
|
||||
<Route path={oraSettingsUrl} element={<PageWrap><ORASettings onClose={jest.fn()} /></PageWrap>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PagesAndResourcesProvider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
)
|
||||
);
|
||||
|
||||
const mockStore = async ({
|
||||
apiStatus,
|
||||
enabled,
|
||||
}) => {
|
||||
const settings = ['forceOnFlexiblePeerOpenassessments'];
|
||||
const fetchCourseAppsUrl = `${getCourseAppsApiUrl()}/${courseId}`;
|
||||
const fetchAdvancedSettingsUrl = `${getCourseAdvancedSettingsApiUrl()}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(fetchCourseAppsUrl).reply(
|
||||
200,
|
||||
[{
|
||||
allowed_operations: { enable: false, configure: true },
|
||||
description: 'setting',
|
||||
documentation_links: { learnMoreConfiguration: '' },
|
||||
enabled,
|
||||
id: 'ora_settings',
|
||||
name: 'Flexible Peer Grading for ORAs',
|
||||
}],
|
||||
);
|
||||
axiosMock.onGet(fetchAdvancedSettingsUrl).reply(
|
||||
apiStatus,
|
||||
{ force_on_flexible_peer_openassessments: { value: enabled } },
|
||||
);
|
||||
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseAppSettings(courseId, settings), store.dispatch);
|
||||
const props = {
|
||||
onClose: jest.fn().mockName('onClose'),
|
||||
intl: {
|
||||
formatMessage: (message) => message.defaultMessage,
|
||||
},
|
||||
};
|
||||
|
||||
describe('ORASettings', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(inititalState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('Flexible peer grading configuration modal is visible', async () => {
|
||||
renderComponent();
|
||||
expect(screen.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays "Configure Flexible Peer Grading" heading', async () => {
|
||||
renderComponent();
|
||||
const headingElement = screen.getByText(messages.heading.defaultMessage);
|
||||
|
||||
expect(headingElement).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays loading component', () => {
|
||||
renderComponent();
|
||||
const loadingElement = screen.getByRole('status');
|
||||
|
||||
expect(within(loadingElement).getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays Connection Error Alert', async () => {
|
||||
await mockStore({ apiStatus: 404, enabled: true });
|
||||
renderComponent();
|
||||
const errorAlert = screen.getByRole('alert');
|
||||
|
||||
expect(within(errorAlert).getByText('We encountered a technical error when loading this page.', { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays Permissions Error Alert', async () => {
|
||||
await mockStore({ apiStatus: 403, enabled: true });
|
||||
renderComponent();
|
||||
const errorAlert = screen.getByRole('alert');
|
||||
|
||||
expect(within(errorAlert).getByText('You are not authorized to view this page', { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
|
||||
await mockStore({ apiStatus: 200, enabled: true });
|
||||
renderComponent();
|
||||
|
||||
const checkbox = await screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
|
||||
expect(checkbox).toBeChecked();
|
||||
|
||||
await waitFor(() => {
|
||||
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
||||
const enableBadge = screen.getByTestId('enable-badge');
|
||||
|
||||
expect(label).toBeVisible();
|
||||
|
||||
expect(enableBadge).toHaveTextContent('Enabled');
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays title, helper text and hides badge when flexible peer grading button is disabled', async () => {
|
||||
renderComponent();
|
||||
await mockStore({ apiStatus: 200, enabled: false });
|
||||
|
||||
const label = await screen.findByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
||||
const enableBadge = screen.queryByTestId('enable-badge');
|
||||
|
||||
expect(label).toBeVisible();
|
||||
|
||||
expect(enableBadge).toBeNull();
|
||||
it('should render', () => {
|
||||
const wrapper = shallow(<ORASettings {...props} />);
|
||||
expect(wrapper.snapshot).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ORASettings should render 1`] = `
|
||||
<AppSettingsModal
|
||||
appId="ora_settings"
|
||||
hideAppToggle={true}
|
||||
initialValues={
|
||||
Object {
|
||||
"enableFlexiblePeerGrade": "abitrary value",
|
||||
}
|
||||
}
|
||||
onClose={[MockFunction onClose]}
|
||||
onSettingsSave={[Function]}
|
||||
title={
|
||||
<div>
|
||||
<p>
|
||||
Configure open response assessment
|
||||
</p>
|
||||
<div
|
||||
className="pt-3"
|
||||
>
|
||||
<withDeprecatedProps(Hyperlink)
|
||||
className="text-primary-500 small"
|
||||
destination="https://learnmore.test"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about open response assessment settings
|
||||
</withDeprecatedProps(Hyperlink)>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
validationSchema={
|
||||
Object {
|
||||
"enableFlexiblePeerGrade": "Yub.boolean",
|
||||
}
|
||||
}
|
||||
>
|
||||
[Function]
|
||||
</AppSettingsModal>
|
||||
`;
|
||||
@@ -1,32 +0,0 @@
|
||||
export const courseId = 'course-v1:org+num+run';
|
||||
|
||||
export const inititalState = {
|
||||
courseDetail: {
|
||||
courseId,
|
||||
status: 'successful',
|
||||
},
|
||||
pagesAndResources: {
|
||||
courseAppIds: ['ora_settings'],
|
||||
loadingStatus: 'in-progress',
|
||||
savingStatus: '',
|
||||
courseAppsApiStatus: {},
|
||||
courseAppSettings: {},
|
||||
},
|
||||
models: {
|
||||
courseApps: {
|
||||
ora_settings: {
|
||||
id: 'ora_settings',
|
||||
name: 'Flexible Peer Grading',
|
||||
enabled: true,
|
||||
description: 'Enable flexible peer grading',
|
||||
allowedOperations: {
|
||||
enable: false,
|
||||
configure: true,
|
||||
},
|
||||
documentationLinks: {
|
||||
learnMoreConfiguration: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -3,51 +3,19 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.pages-resources.ora.heading',
|
||||
defaultMessage: 'Configure Flexible Peer Grading',
|
||||
description: 'Title for the modal dialog header',
|
||||
defaultMessage: 'Configure open response assessment',
|
||||
},
|
||||
ORASettingsHelpLink: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.link',
|
||||
defaultMessage: 'Learn more about open response assessment settings',
|
||||
description: 'Descriptive text for the hyperlink to the docs site',
|
||||
},
|
||||
enableFlexPeerGradeLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.label',
|
||||
defaultMessage: 'Flex Peer Grading',
|
||||
description: 'Label for form switch',
|
||||
},
|
||||
enableFlexPeerGradeHelp: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.help',
|
||||
defaultMessage: 'Turn on Flexible Peer Grading for all open response assessments in the course with peer grading.',
|
||||
description: 'Help text describing what happens when the switch is enabled',
|
||||
},
|
||||
enabledBadgeLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.enabled-badge.label',
|
||||
defaultMessage: 'Enabled',
|
||||
description: 'Label for badge that show users that a setting is enabled',
|
||||
},
|
||||
cancelLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.cancel-button.label',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Label for button that cancels user changes',
|
||||
},
|
||||
saveLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-button.label',
|
||||
defaultMessage: 'Save',
|
||||
description: 'Label for button that saves user changes',
|
||||
},
|
||||
pendingSaveLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.pending-save-button.label',
|
||||
defaultMessage: 'Saving',
|
||||
description: 'Label for button that has pending api save calls',
|
||||
},
|
||||
errorSavingTitle: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.title',
|
||||
defaultMessage: 'We couldn\'t apply your changes.',
|
||||
},
|
||||
errorSavingMessage: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.message',
|
||||
defaultMessage: 'Please check your entries and try again.',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,16 +3,15 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Open Response Assessment configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"react-redux": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
@@ -25,8 +25,7 @@ import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/Pa
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const ProctoringSettings = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const ProctoringSettings = ({ intl, onClose }) => {
|
||||
const initialFormValues = {
|
||||
enableProctoredExams: false,
|
||||
proctoringProvider: false,
|
||||
@@ -65,8 +64,6 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
}
|
||||
|
||||
const { courseId } = useContext(PagesAndResourcesContext);
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
const org = courseDetails?.org;
|
||||
const appInfo = useModel('courseApps', 'proctoring');
|
||||
const alertRef = React.createRef();
|
||||
const saveStatusAlertRef = React.createRef();
|
||||
@@ -149,9 +146,9 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
setSaveSuccess(true);
|
||||
setSaveError(false);
|
||||
setSubmissionInProgress(false);
|
||||
}).catch((error) => {
|
||||
}).catch(() => {
|
||||
setSaveSuccess(false);
|
||||
setSaveError(error);
|
||||
setSaveError(true);
|
||||
setSubmissionInProgress(false);
|
||||
});
|
||||
}
|
||||
@@ -461,44 +458,6 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
}
|
||||
|
||||
function renderSaveError() {
|
||||
let errorMessage = (
|
||||
<FormattedMessage
|
||||
id="authoring.proctoring.alert.error"
|
||||
defaultMessage={`
|
||||
We encountered a technical error while trying to save proctored exam settings.
|
||||
This might be a temporary issue, so please try again in a few minutes.
|
||||
If the problem persists, please go to the {support_link} for help.
|
||||
`}
|
||||
values={{
|
||||
support_link: (
|
||||
<Alert.Link href={getConfig().SUPPORT_URL}>
|
||||
{intl.formatMessage(messages['authoring.proctoring.support.text'])}
|
||||
</Alert.Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (saveError?.response.status === 403) {
|
||||
errorMessage = (
|
||||
<FormattedMessage
|
||||
id="authoring.proctoring.alert.error.forbidden"
|
||||
defaultMessage={`
|
||||
You do not have permission to edit proctored exam settings for this course.
|
||||
If you are a course team member and this problem persists,
|
||||
please go to the {support_link} for help.
|
||||
`}
|
||||
values={{
|
||||
support_link: (
|
||||
<Alert.Link href={getConfig().SUPPORT_URL}>
|
||||
{intl.formatMessage(messages['authoring.proctoring.support.text'])}
|
||||
</Alert.Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant="danger"
|
||||
@@ -508,7 +467,21 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
onClose={() => setSaveError(false)}
|
||||
dismissible
|
||||
>
|
||||
{errorMessage}
|
||||
<FormattedMessage
|
||||
id="authoring.examsettings.alert.error"
|
||||
defaultMessage={`
|
||||
We encountered a technical error while trying to save proctored exam settings.
|
||||
This might be a temporary issue, so please try again in a few minutes.
|
||||
If the problem persists, please go to the {support_link} for help.
|
||||
`}
|
||||
values={{
|
||||
support_link: (
|
||||
<Alert.Link href={getConfig().SUPPORT_URL}>
|
||||
{intl.formatMessage(messages['authoring.proctoring.support.text'])}
|
||||
</Alert.Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -517,7 +490,7 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
Promise.all([
|
||||
StudioApiService.getProctoredExamSettingsData(courseId),
|
||||
ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(),
|
||||
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders(org) : Promise.resolve(),
|
||||
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders() : Promise.resolve(),
|
||||
])
|
||||
.then(
|
||||
([settingsResponse, examConfigResponse, ltiProvidersResponse]) => {
|
||||
@@ -653,9 +626,10 @@ const ProctoringSettings = ({ onClose }) => {
|
||||
};
|
||||
|
||||
ProctoringSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
ProctoringSettings.defaultProps = {};
|
||||
|
||||
export default ProctoringSettings;
|
||||
export default injectIntl(ProctoringSettings);
|
||||
|
||||
@@ -15,9 +15,8 @@ import initializeStore from 'CourseAuthoring/store';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import ProctoredExamSettings from './Settings';
|
||||
|
||||
const courseId = 'course-v1%3AedX%2BDemoX%2BDemo_Course';
|
||||
const defaultProps = {
|
||||
courseId,
|
||||
courseId: 'course-v1%3AedX%2BDemoX%2BDemo_Course',
|
||||
onClose: () => {},
|
||||
};
|
||||
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
|
||||
@@ -35,7 +34,7 @@ const intlWrapper = children => (
|
||||
let axiosMock;
|
||||
|
||||
describe('ProctoredExamSettings', () => {
|
||||
function setupApp(isAdmin = true, org = undefined) {
|
||||
function setupApp(isAdmin = true) {
|
||||
mergeConfig({
|
||||
EXAMS_BASE_URL: 'http://exams.testing.co',
|
||||
}, 'CourseAuthoringConfig');
|
||||
@@ -53,18 +52,12 @@ describe('ProctoredExamSettings', () => {
|
||||
courseApps: {
|
||||
proctoring: {},
|
||||
},
|
||||
courseDetails: {
|
||||
[courseId]: {
|
||||
start: Date(),
|
||||
},
|
||||
},
|
||||
...(org ? { courseDetails: { [courseId]: { org } } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers${org ? `?org=${org}` : ''}`,
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
|
||||
).reply(200, [
|
||||
{
|
||||
name: 'test_lti',
|
||||
@@ -110,7 +103,9 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
});
|
||||
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsNo');
|
||||
expect(zendeskTicketInput.checked).toEqual(true);
|
||||
});
|
||||
@@ -120,7 +115,9 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
fireEvent.change(selectElement, { target: { value: 'software_secure' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'software_secure' } });
|
||||
});
|
||||
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
|
||||
expect(zendeskTicketInput.checked).toEqual(true);
|
||||
});
|
||||
@@ -130,7 +127,9 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
fireEvent.change(selectElement, { target: { value: 'mockproc' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'mockproc' } });
|
||||
});
|
||||
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
|
||||
expect(zendeskTicketInput.checked).toEqual(true);
|
||||
});
|
||||
@@ -177,7 +176,9 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
let enabledProctoredExamCheck = screen.getAllByLabelText('Proctored exams', { exact: false })[0];
|
||||
expect(enabledProctoredExamCheck.checked).toEqual(true);
|
||||
fireEvent.click(enabledProctoredExamCheck, { target: { value: false } });
|
||||
await act(async () => {
|
||||
fireEvent.click(enabledProctoredExamCheck, { target: { value: false } });
|
||||
});
|
||||
enabledProctoredExamCheck = screen.getByLabelText('Proctored exams');
|
||||
expect(enabledProctoredExamCheck.checked).toEqual(false);
|
||||
expect(screen.queryByText('Allow opting out of proctored exams')).toBeNull();
|
||||
@@ -192,7 +193,9 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
});
|
||||
expect(screen.queryByTestId('allowOptingOutRadio')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
|
||||
@@ -234,9 +237,13 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(selectButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||
@@ -245,7 +252,9 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
// verify alert link links to offending input
|
||||
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
||||
fireEvent.click(errorLink);
|
||||
await act(async () => {
|
||||
fireEvent.click(errorLink);
|
||||
});
|
||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||
});
|
||||
@@ -256,12 +265,18 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
const selectElement = screen.getByDisplayValue('proctortrack');
|
||||
fireEvent.change(selectElement, { target: { value: provider } });
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: provider } });
|
||||
});
|
||||
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
const proctoringForm = screen.getByTestId('proctoringForm');
|
||||
fireEvent.submit(proctoringForm);
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||
@@ -271,7 +286,9 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
// verify alert link links to offending input
|
||||
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
||||
fireEvent.click(errorLink);
|
||||
await act(async () => {
|
||||
fireEvent.click(errorLink);
|
||||
});
|
||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||
});
|
||||
@@ -281,11 +298,15 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
});
|
||||
const enableProctoringElement = screen.getByText('Proctored exams');
|
||||
fireEvent.click(enableProctoringElement);
|
||||
await act(async () => fireEvent.click(enableProctoringElement));
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(selectButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||
@@ -299,22 +320,24 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
const enableProctoringElement = screen.getByText('Proctored exams');
|
||||
fireEvent.click(enableProctoringElement);
|
||||
await act(async () => fireEvent.click(enableProctoringElement));
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(selectButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
|
||||
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => {
|
||||
@@ -322,20 +345,22 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(selectButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
|
||||
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => {
|
||||
@@ -345,7 +370,9 @@ describe('ProctoredExamSettings', () => {
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -355,9 +382,13 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
|
||||
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||
@@ -368,8 +399,12 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
fireEvent.submit(selectEscalationEmailElement);
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.submit(selectEscalationEmailElement);
|
||||
});
|
||||
// if the error appears, the form has been submitted
|
||||
expect(screen.getByTestId('escalationEmailError')).toBeDefined();
|
||||
});
|
||||
@@ -423,16 +458,6 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
|
||||
it('Sends the org to the proctoring provider endpoint', async () => {
|
||||
const isAdmin = false;
|
||||
const org = 'test-org';
|
||||
setupApp(isAdmin, org);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
|
||||
it('Enables all proctoring provider options if user administrator and it is after start date', async () => {
|
||||
const isAdmin = true;
|
||||
setupApp(isAdmin);
|
||||
@@ -544,9 +569,12 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
describe('Connection states', () => {
|
||||
it('Shows the spinner before the connection is complete', async () => {
|
||||
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
|
||||
const spinner = await screen.findByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading...');
|
||||
await act(async () => {
|
||||
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
|
||||
// This expectation is _inside_ the `act` intentionally, so that it executes immediately.
|
||||
const spinner = screen.getByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading...');
|
||||
});
|
||||
});
|
||||
|
||||
it('Show connection error message when we suffer studio server side error', async () => {
|
||||
@@ -600,7 +628,9 @@ describe('ProctoredExamSettings', () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
let submitButton = screen.getByTestId('submissionButton');
|
||||
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
|
||||
fireEvent.click(submitButton);
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
submitButton = screen.getByTestId('submissionButton');
|
||||
expect(submitButton).toHaveAttribute('disabled');
|
||||
@@ -610,13 +640,19 @@ describe('ProctoredExamSettings', () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the provider to proctortrack and set the email
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
});
|
||||
const escalationEmail = screen.getByTestId('escalationEmail');
|
||||
expect(escalationEmail.value).toEqual('test@example.com');
|
||||
fireEvent.change(escalationEmail, { target: { value: 'proctortrack@example.com' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(escalationEmail, { target: { value: 'proctortrack@example.com' } });
|
||||
});
|
||||
expect(escalationEmail.value).toEqual('proctortrack@example.com');
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
||||
proctored_exam_settings: {
|
||||
@@ -628,13 +664,11 @@ describe('ProctoredExamSettings', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
|
||||
@@ -644,7 +678,9 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
|
||||
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
||||
proctored_exam_settings: {
|
||||
@@ -655,28 +691,32 @@ describe('ProctoredExamSettings', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the provider to test_lti and set the email
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
});
|
||||
|
||||
const escalationEmail = screen.getByTestId('escalationEmail');
|
||||
expect(escalationEmail.value).toEqual('test@example.com');
|
||||
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
|
||||
});
|
||||
expect(escalationEmail.value).toEqual('test_lti@example.com');
|
||||
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
// update exam service config
|
||||
expect(axiosMock.history.patch.length).toBe(1);
|
||||
@@ -696,19 +736,19 @@ describe('ProctoredExamSettings', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
// update exam service config
|
||||
expect(axiosMock.history.patch.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
||||
@@ -726,13 +766,11 @@ describe('ProctoredExamSettings', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it('Does not update exam service if lti is not enabled in studio', async () => {
|
||||
@@ -752,7 +790,9 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
// does not update exam service config
|
||||
expect(axiosMock.history.patch.length).toBe(0);
|
||||
// does update studio
|
||||
@@ -766,13 +806,11 @@ describe('ProctoredExamSettings', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it('Makes studio API call generated error', async () => {
|
||||
@@ -782,15 +820,15 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveError');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
const errorAlert = screen.getByTestId('saveError');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it('Makes exams API call generated error', async () => {
|
||||
@@ -800,33 +838,15 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveError');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
});
|
||||
|
||||
test('Exams API permission error', async () => {
|
||||
axiosMock.onPatch(
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||
).reply(403, 'error');
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveError');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('You do not have permission to edit proctored exam settings for this course'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
const errorAlert = screen.getByTestId('saveError');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it('Manages focus correctly after different save statuses', async () => {
|
||||
@@ -837,30 +857,30 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveError');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
const errorAlert = screen.getByTestId('saveError');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
|
||||
// now make a call that will allow for a successful save
|
||||
axiosMock.onPost(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, 'success');
|
||||
fireEvent.click(submitButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(axiosMock.history.post.length).toBe(2);
|
||||
await waitFor(() => {
|
||||
const successAlert = screen.getByTestId('saveSuccess');
|
||||
expect(successAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(successAlert);
|
||||
});
|
||||
const successAlert = screen.getByTestId('saveSuccess');
|
||||
expect(successAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(successAlert);
|
||||
});
|
||||
|
||||
it('Include Zendesk ticket in post request if user is not an admin', async () => {
|
||||
@@ -871,9 +891,13 @@ describe('ProctoredExamSettings', () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the proctoring provider
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
});
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
||||
proctored_exam_settings: {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'authoring.proctoring.alert.forbidden': {
|
||||
id: 'authoring.proctoring.alert.forbidden',
|
||||
defaultMessage: 'You do not have permission to edit proctored exam settings for this course. If you are a course team member and this problem persists, please go to the {support_link} for help.',
|
||||
description: 'Alert message for proctoring settings permission error.',
|
||||
},
|
||||
'authoring.proctoring.no': {
|
||||
id: 'authoring.proctoring.no',
|
||||
defaultMessage: 'No',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Proctoring configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"classnames": "*",
|
||||
@@ -13,7 +13,7 @@
|
||||
"moment": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import * as Yup from 'yup';
|
||||
@@ -8,8 +8,7 @@ import { useAppSetting } from 'CourseAuthoring/utils';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
const ProgressSettings = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const ProgressSettings = ({ intl, onClose }) => {
|
||||
const [disableProgressGraph, saveSetting] = useAppSetting('disableProgressGraph');
|
||||
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toString().toLowerCase() === 'true';
|
||||
|
||||
@@ -49,7 +48,8 @@ const ProgressSettings = ({ onClose }) => {
|
||||
};
|
||||
|
||||
ProgressSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ProgressSettings;
|
||||
export default injectIntl(ProgressSettings);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Progress configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
@@ -11,7 +11,7 @@
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Form, TransitionReplace } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useState } from 'react';
|
||||
@@ -30,9 +30,8 @@ const TeamTypeNameMessage = {
|
||||
};
|
||||
|
||||
const GroupEditor = ({
|
||||
group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
|
||||
intl, group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [isDeleting, setDeleting] = useState(false);
|
||||
const [isOpen, setOpen] = useState(group.id === null);
|
||||
const initiateDeletion = () => setDeleting(true);
|
||||
@@ -150,6 +149,7 @@ export const groupShape = PropTypes.shape({
|
||||
});
|
||||
|
||||
GroupEditor.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
fieldNameCommonBase: PropTypes.string.isRequired,
|
||||
errors: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
@@ -170,4 +170,4 @@ GroupEditor.defaultProps = {
|
||||
},
|
||||
};
|
||||
|
||||
export default GroupEditor;
|
||||
export default injectIntl(GroupEditor);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Form } from '@openedx/paragon';
|
||||
import { Add } from '@openedx/paragon/icons';
|
||||
|
||||
@@ -17,16 +17,15 @@ import messages from './messages';
|
||||
setupYupExtensions();
|
||||
|
||||
const TeamSettings = ({
|
||||
intl,
|
||||
onClose,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [teamsConfiguration, saveSettings] = useAppSetting('teamsConfiguration');
|
||||
const blankNewGroup = {
|
||||
name: '',
|
||||
description: '',
|
||||
type: GroupTypes.OPEN,
|
||||
maxTeamSize: null,
|
||||
userPartitionId: null,
|
||||
id: null,
|
||||
key: uuid(),
|
||||
};
|
||||
@@ -39,7 +38,6 @@ const TeamSettings = ({
|
||||
type: group.type,
|
||||
description: group.description,
|
||||
max_team_size: group.maxTeamSize,
|
||||
user_partition_id: group.userPartitionId,
|
||||
}));
|
||||
return saveSettings({
|
||||
team_sets: groups,
|
||||
@@ -166,7 +164,8 @@ const TeamSettings = ({
|
||||
};
|
||||
|
||||
TeamSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default TeamSettings;
|
||||
export default injectIntl(TeamSettings);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Teams configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"formik": "*",
|
||||
@@ -13,7 +13,7 @@
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import * as Yup from 'yup';
|
||||
@@ -8,8 +8,7 @@ import { useAppSetting } from 'CourseAuthoring/utils';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
const WikiSettings = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const WikiSettings = ({ intl, onClose }) => {
|
||||
const [enablePublicWiki, saveSetting] = useAppSetting('allowPublicWikiAccess');
|
||||
const handleSettingsSave = (values) => saveSetting(values.enablePublicWiki);
|
||||
|
||||
@@ -33,7 +32,7 @@ const WikiSettings = ({ onClose }) => {
|
||||
label={intl.formatMessage(messages.enablePublicWikiLabel)}
|
||||
helpText={intl.formatMessage(messages.enablePublicWikiHelp)}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onBlue={handleBlur}
|
||||
checked={values.enablePublicWiki}
|
||||
/>
|
||||
)
|
||||
@@ -43,7 +42,8 @@ const WikiSettings = ({ onClose }) => {
|
||||
};
|
||||
|
||||
WikiSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default WikiSettings;
|
||||
export default injectIntl(WikiSettings);
|
||||
|
||||
@@ -26,8 +26,8 @@ const messages = defineMessages({
|
||||
},
|
||||
enablePublicWikiHelp: {
|
||||
id: 'course-authoring.pages-resources.wiki.enable-public-wiki.help',
|
||||
defaultMessage: `If enabled, any registered user can view the course wiki
|
||||
even if they are not enrolled in the course`,
|
||||
defaultMessage: `If enabled, edX users can view the course wiki even when
|
||||
they're not enrolled in the course.`,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Wiki configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
@@ -11,7 +11,7 @@
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useContext, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -9,8 +10,7 @@ import messages from './messages';
|
||||
|
||||
import { fetchXpertSettings } from './data/thunks';
|
||||
|
||||
const XpertUnitSummarySettings = () => {
|
||||
const intl = useIntl();
|
||||
const XpertUnitSummarySettings = ({ intl }) => {
|
||||
const { path: pagesAndResourcesPath, courseId } = useContext(PagesAndResourcesContext);
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
@@ -38,4 +38,8 @@ const XpertUnitSummarySettings = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default XpertUnitSummarySettings;
|
||||
XpertUnitSummarySettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(XpertUnitSummarySettings);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||
import {
|
||||
findByTestId, queryByTestId, render, waitFor, getByText, fireEvent,
|
||||
queryByTestId, render, waitFor, getByText, fireEvent,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
@@ -106,9 +106,8 @@ describe('XpertUnitSummarySettings', () => {
|
||||
});
|
||||
|
||||
test('Shows switch on if enabled from backend', async () => {
|
||||
const enableBadge = await findByTestId(container, 'enable-badge');
|
||||
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).toBeTruthy();
|
||||
expect(enableBadge).toBeTruthy();
|
||||
expect(queryByTestId(container, 'enable-badge')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Shows switch on if disabled from backend', async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Xpert Unit Summaries configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"formik": "*",
|
||||
@@ -14,7 +14,7 @@
|
||||
"react-router-dom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
Alert,
|
||||
@@ -70,40 +70,38 @@ AppSettingsForm.defaultProps = {
|
||||
};
|
||||
|
||||
const SettingsModalBase = ({
|
||||
title, onClose, variant, isMobile, children, footer,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<ModalDialog
|
||||
title={title}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
variant={variant}
|
||||
hasCloseButton={isMobile}
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title data-testid="modal-title">
|
||||
{title}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
{children}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer className="p-4">
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{intl.formatMessage(messages.cancel)}
|
||||
</ModalDialog.CloseButton>
|
||||
{footer}
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
intl, title, onClose, variant, isMobile, children, footer,
|
||||
}) => (
|
||||
<ModalDialog
|
||||
title={title}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
variant={variant}
|
||||
hasCloseButton={isMobile}
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title data-testid="modal-title">
|
||||
{title}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
{children}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer className="p-4">
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{intl.formatMessage(messages.cancel)}
|
||||
</ModalDialog.CloseButton>
|
||||
{footer}
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</ModalDialog>
|
||||
);
|
||||
|
||||
SettingsModalBase.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
variant: PropTypes.oneOf(['default', 'dark']).isRequired,
|
||||
@@ -117,11 +115,11 @@ SettingsModalBase.defaultProps = {
|
||||
};
|
||||
|
||||
const ResetUnitsButton = ({
|
||||
intl,
|
||||
courseId,
|
||||
checked,
|
||||
visible,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const resetStatusRequestStatus = useSelector(getResetStatus);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -139,12 +137,12 @@ const ResetUnitsButton = ({
|
||||
|
||||
const getResetButtonState = () => {
|
||||
switch (resetStatusRequestStatus) {
|
||||
case RequestStatus.PENDING:
|
||||
return 'pending';
|
||||
case RequestStatus.SUCCESSFUL:
|
||||
return 'finish';
|
||||
default:
|
||||
return 'default';
|
||||
case RequestStatus.PENDING:
|
||||
return 'pending';
|
||||
case RequestStatus.SUCCESSFUL:
|
||||
return 'finish';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -187,6 +185,7 @@ const ResetUnitsButton = ({
|
||||
};
|
||||
|
||||
ResetUnitsButton.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
checked: PropTypes.oneOf(['true', 'false']).isRequired,
|
||||
visible: PropTypes.bool,
|
||||
@@ -197,6 +196,7 @@ ResetUnitsButton.defaultProps = {
|
||||
};
|
||||
|
||||
const SettingsModal = ({
|
||||
intl,
|
||||
appId,
|
||||
title,
|
||||
children,
|
||||
@@ -213,7 +213,6 @@ const SettingsModal = ({
|
||||
allUnitsEnabledText,
|
||||
noUnitsEnabledText,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { courseId } = useContext(PagesAndResourcesContext);
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
const updateSettingsRequestStatus = useSelector(getSavingStatus);
|
||||
@@ -247,7 +246,7 @@ const SettingsModal = ({
|
||||
success = success && await onSettingsSave(values);
|
||||
}
|
||||
setSaveError(!success);
|
||||
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
|
||||
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line no-unused-expressions
|
||||
};
|
||||
|
||||
const handleFormikSubmit = ({ handleSubmit, errors }) => async (event) => {
|
||||
@@ -373,6 +372,7 @@ const SettingsModal = ({
|
||||
>
|
||||
{allUnitsEnabledText}
|
||||
<ResetUnitsButton
|
||||
intl={intl}
|
||||
courseId={courseId}
|
||||
checked={formikProps.values.checked}
|
||||
visible={formikProps.values.checked === 'true'}
|
||||
@@ -385,6 +385,7 @@ const SettingsModal = ({
|
||||
>
|
||||
{noUnitsEnabledText}
|
||||
<ResetUnitsButton
|
||||
intl={intl}
|
||||
courseId={courseId}
|
||||
checked={formikProps.values.checked}
|
||||
visible={formikProps.values.checked === 'false'}
|
||||
@@ -422,6 +423,7 @@ const SettingsModal = ({
|
||||
};
|
||||
|
||||
SettingsModal.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
appId: PropTypes.string.isRequired,
|
||||
children: PropTypes.func,
|
||||
@@ -448,4 +450,4 @@ SettingsModal.defaultProps = {
|
||||
enableReinitialize: false,
|
||||
};
|
||||
|
||||
export default SettingsModal;
|
||||
export default injectIntl(SettingsModal);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "~@openedx/paragon/styles/scss/core/utilities-only";
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@openedx/paragon/scss/core/utilities-only";
|
||||
|
||||
.summary-radio {
|
||||
display: flex;
|
||||
|
||||
@@ -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,17 +5,39 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import { StudioFooterSlot } from '@edx/frontend-component-footer';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import Header from './header';
|
||||
import { fetchCourseDetail } from './data/thunks';
|
||||
import { useModel } from './generic/model-store';
|
||||
import NotFoundAlert from './generic/NotFoundAlert';
|
||||
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
||||
import { fetchOnlyStudioHomeData } from './studio-home/data/thunks';
|
||||
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
|
||||
import { RequestStatus } from './data/constants';
|
||||
import Loading from './generic/Loading';
|
||||
|
||||
const AppHeader = ({
|
||||
courseNumber, courseOrg, courseTitle, courseId,
|
||||
}) => (
|
||||
<Header
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
);
|
||||
|
||||
AppHeader.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
courseNumber: PropTypes.string,
|
||||
courseOrg: PropTypes.string,
|
||||
courseTitle: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
AppHeader.defaultProps = {
|
||||
courseNumber: null,
|
||||
courseOrg: null,
|
||||
};
|
||||
|
||||
const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -23,10 +45,6 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
dispatch(fetchCourseDetail(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchOnlyStudioHomeData());
|
||||
}, []);
|
||||
|
||||
const courseDetail = useModel('courseDetails', courseId);
|
||||
|
||||
const courseNumber = courseDetail ? courseDetail.number : null;
|
||||
@@ -49,23 +67,23 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
|
||||
{/* While V2 Editors are temporarily served from their own pages
|
||||
using url pattern containing /editor/,
|
||||
we shouldn't have the header and footer on these pages.
|
||||
This functionality will be removed in TNL-9591 */}
|
||||
{inProgress ? !isEditor && <Loading />
|
||||
: (!isEditor && (
|
||||
<Header
|
||||
number={courseNumber}
|
||||
org={courseOrg}
|
||||
title={courseTitle}
|
||||
contextId={courseId}
|
||||
<AppHeader
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{children}
|
||||
{!inProgress && !isEditor && <StudioFooterSlot />}
|
||||
{!inProgress && !isEditor && <StudioFooter />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 } from './data/thunks';
|
||||
import { getApiWaffleFlagsUrl } from './data/api';
|
||||
import { initializeMocks, render } from './testUtils';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
let mockPathname = '/evilguy/';
|
||||
@@ -19,13 +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, {});
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
describe('Editor Pages Load no header', () => {
|
||||
@@ -41,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();
|
||||
@@ -52,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();
|
||||
@@ -82,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 () => {
|
||||
@@ -93,28 +118,16 @@ 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();
|
||||
expect(wrapper.queryByTestId('notFoundAlert')).not.toBeInTheDocument();
|
||||
});
|
||||
const mockStoreDenied = async () => {
|
||||
const studioApiBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const courseAppsApiUrl = `${studioApiBaseUrl}/api/course_apps/v1/apps`;
|
||||
|
||||
axiosMock.onGet(
|
||||
`${courseAppsApiUrl}/${courseId}`,
|
||||
).reply(403);
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
};
|
||||
test('renders PermissionDeniedAlert when courseAppsApiStatus is DENIED', async () => {
|
||||
mockPathname = '/editor/';
|
||||
await mockStoreDenied();
|
||||
|
||||
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
|
||||
expect(await wrapper.findByTestId('permissionDeniedAlert')).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 './textbooks';
|
||||
import CourseAuthoringPage from './CourseAuthoringPage';
|
||||
import { PagesAndResources } from './pages-and-resources';
|
||||
import EditorContainer from './editors/EditorContainer';
|
||||
@@ -17,16 +16,11 @@ import ScheduleAndDetails from './schedule-and-details';
|
||||
import { GradingSettings } from './grading-settings';
|
||||
import CourseTeam from './course-team/CourseTeam';
|
||||
import { CourseUpdates } from './course-updates';
|
||||
import { CourseUnit, SubsectionUnitRedirect } from './course-unit';
|
||||
import { Certificates } from './certificates';
|
||||
import { CourseUnit } from './course-unit';
|
||||
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 +52,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>}
|
||||
@@ -82,24 +72,20 @@ const CourseAuthoringRoutes = () => {
|
||||
path="custom-pages/*"
|
||||
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="/subsection/:subsectionId"
|
||||
element={<PageWrap><SubsectionUnitRedirect courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
{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"
|
||||
@@ -113,10 +99,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>}
|
||||
@@ -129,22 +111,10 @@ 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,14 +1,17 @@
|
||||
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 { getApiWaffleFlagsUrl } from './data/api';
|
||||
import {
|
||||
screen, initializeMocks, render, waitFor,
|
||||
} from './testUtils';
|
||||
import initializeStore from './store';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const pagesAndResourcesMockText = 'Pages And Resources';
|
||||
const editorContainerMockText = 'Editor Container';
|
||||
const videoSelectorContainerMockText = 'Video Selector Container';
|
||||
const customPagesMockText = 'Custom Pages';
|
||||
let store;
|
||||
const mockComponentFn = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
@@ -18,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,
|
||||
@@ -47,57 +49,68 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
|
||||
});
|
||||
|
||||
describe('<CourseAuthoringRoutes>', () => {
|
||||
beforeEach(async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
axiosMock
|
||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
||||
.reply(200, {});
|
||||
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: 'chapter',
|
||||
blockTypeDisplay: 'Section',
|
||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
|
||||
displayName: 'Chapter 1',
|
||||
},
|
||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
|
||||
sourceContextTitle: 'Demonstration Course',
|
||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
|
||||
};
|
||||
@@ -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,4 +0,0 @@
|
||||
export { default as clipboardUnit } from './clipboardUnit';
|
||||
export { default as clipboardSubsection } from './clipboardSubsection';
|
||||
export { default as clipboardXBlock } from './clipboardXBlock';
|
||||
export { default as clipboardSection } from './clipboardSection';
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
@@ -95,4 +95,4 @@ AccessibilityBody.propTypes = {
|
||||
email: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default AccessibilityBody;
|
||||
export default injectIntl(AccessibilityBody);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedMessage, FormattedDate, FormattedTime, useIntl,
|
||||
injectIntl, FormattedMessage, intlShape, FormattedDate, FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Alert, Form, Stack, StatefulButton,
|
||||
@@ -15,8 +15,9 @@ import messages from './messages';
|
||||
|
||||
const AccessibilityForm = ({
|
||||
accessibilityEmail,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
errors,
|
||||
values,
|
||||
@@ -138,6 +139,8 @@ const AccessibilityForm = ({
|
||||
|
||||
AccessibilityForm.propTypes = {
|
||||
accessibilityEmail: PropTypes.string.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default AccessibilityForm;
|
||||
export default injectIntl(AccessibilityForm);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
render,
|
||||
act,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
@@ -73,24 +74,22 @@ describe('<AccessibilityPolicyForm />', () => {
|
||||
describe('statusAlert', () => {
|
||||
let formSections;
|
||||
let submitButton;
|
||||
let user;
|
||||
beforeEach(async () => {
|
||||
user = userEvent.setup();
|
||||
renderComponent();
|
||||
formSections = screen.getAllByRole('textbox');
|
||||
|
||||
await user.type(formSections[0], 'email@email.com');
|
||||
await user.type(formSections[1], 'test name');
|
||||
await user.type(formSections[2], 'feedback message');
|
||||
|
||||
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 user.click(submitButton);
|
||||
|
||||
await act(async () => {
|
||||
userEvent.click(submitButton);
|
||||
});
|
||||
const { savingStatus } = store.getState().accessibilityPage;
|
||||
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
@@ -105,9 +104,9 @@ describe('<AccessibilityPolicyForm />', () => {
|
||||
|
||||
it('shows correct rate limiting message', async () => {
|
||||
axiosMock.onPost(getZendeskrUrl()).reply(429);
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
await act(async () => {
|
||||
userEvent.click(submitButton);
|
||||
});
|
||||
const { savingStatus } = store.getState().accessibilityPage;
|
||||
expect(savingStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
@@ -124,24 +123,23 @@ describe('<AccessibilityPolicyForm />', () => {
|
||||
describe('input validation', () => {
|
||||
let formSections;
|
||||
let submitButton;
|
||||
let user;
|
||||
beforeEach(async () => {
|
||||
user = userEvent.setup();
|
||||
renderComponent();
|
||||
formSections = screen.getAllByRole('textbox');
|
||||
|
||||
await user.type(formSections[0], 'email@email.com');
|
||||
await user.type(formSections[1], 'test name');
|
||||
await user.type(formSections[2], 'feedback message');
|
||||
|
||||
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 user.clear(formSections[0]);
|
||||
await user.clear(formSections[1]);
|
||||
await user.clear(formSections[2]);
|
||||
|
||||
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();
|
||||
|
||||
@@ -153,10 +151,12 @@ describe('<AccessibilityPolicyForm />', () => {
|
||||
});
|
||||
|
||||
it('sumbit button is disabled when trying to submit with all empty fields', async () => {
|
||||
await user.clear(formSections[0]);
|
||||
await user.clear(formSections[1]);
|
||||
await user.clear(formSections[2]);
|
||||
await user.click(submitButton);
|
||||
await act(async () => {
|
||||
userEvent.clear(formSections[0]);
|
||||
userEvent.clear(formSections[1]);
|
||||
userEvent.clear(formSections[2]);
|
||||
userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(submitButton.closest('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Container } from '@openedx/paragon';
|
||||
import { StudioFooterSlot } from '@edx/frontend-component-footer';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
|
||||
import Header from '../header';
|
||||
import messages from './messages';
|
||||
import AccessibilityBody from './AccessibilityBody';
|
||||
import AccessibilityForm from './AccessibilityForm';
|
||||
|
||||
import { COMMUNITY_ACCESSIBILITY_LINK, ACCESSIBILITY_EMAIL } from './constants';
|
||||
|
||||
const AccessibilityPage = () => {
|
||||
const intl = useIntl();
|
||||
const AccessibilityPage = ({
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const communityAccessibilityLink = 'https://www.edx.org/accessibility';
|
||||
const email = 'accessibility@edx.org';
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -24,16 +26,17 @@ const AccessibilityPage = () => {
|
||||
</Helmet>
|
||||
<Header isHiddenMainMenu />
|
||||
<Container size="xl" classNamae="px-4">
|
||||
<AccessibilityBody
|
||||
{...{ email: ACCESSIBILITY_EMAIL, communityAccessibilityLink: COMMUNITY_ACCESSIBILITY_LINK }}
|
||||
/>
|
||||
<AccessibilityForm accessibilityEmail={ACCESSIBILITY_EMAIL} />
|
||||
<AccessibilityBody {...{ email, communityAccessibilityLink }} />
|
||||
<AccessibilityForm accessibilityEmail={email} />
|
||||
</Container>
|
||||
<StudioFooterSlot />
|
||||
<StudioFooter />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AccessibilityPage.propTypes = {};
|
||||
AccessibilityPage.propTypes = {
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default AccessibilityPage;
|
||||
export default injectIntl(AccessibilityPage);
|
||||
|
||||
@@ -1,13 +1,42 @@
|
||||
// @ts-check
|
||||
import { initializeMocks, render, screen } from '../testUtils';
|
||||
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 renderComponent = () => render(<AccessibilityPage />);
|
||||
const initialState = {
|
||||
accessibilityPage: {
|
||||
status: {},
|
||||
},
|
||||
};
|
||||
let store;
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AccessibilityPage />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AccessibilityPolicyPage />', () => {
|
||||
describe('renders', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMocks();
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
});
|
||||
it('contains the policy body', () => {
|
||||
renderComponent();
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export const COMMUNITY_ACCESSIBILITY_LINK = 'https://www.edx.org/accessibility';
|
||||
export const ACCESSIBILITY_EMAIL = 'accessibility@edx.org';
|
||||
@@ -10,11 +10,9 @@ function submitAccessibilityForm({ email, name, message }) {
|
||||
await postAccessibilityForm({ email, name, message });
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
/* istanbul ignore else */
|
||||
if (error.response && error.response.status === 429) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
} else {
|
||||
/* istanbul ignore next */
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
pageTitle: {
|
||||
id: 'course-authoring.accessibility.page.title',
|
||||
id: 'course-authoring.import.page.title',
|
||||
defaultMessage: 'Studio Accessibility Policy| {siteName}',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
Container, Button, Layout, StatefulButton, TransitionReplace,
|
||||
} from '@openedx/paragon';
|
||||
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import Placeholder from '../editors/Placeholder';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import Placeholder from '@edx/frontend-lib-content-components';
|
||||
|
||||
import AlertProctoringError from '../generic/AlertProctoringError';
|
||||
import { useModel } from '../generic/model-store';
|
||||
@@ -26,8 +26,7 @@ import messages from './messages';
|
||||
import ModalError from './modal-error/ModalError';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
|
||||
const AdvancedSettings = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
const AdvancedSettings = ({ intl, courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
|
||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||
@@ -279,7 +278,8 @@ const AdvancedSettings = ({ courseId }) => {
|
||||
};
|
||||
|
||||
AdvancedSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default AdvancedSettings;
|
||||
export default injectIntl(AdvancedSettings);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
render as baseRender,
|
||||
fireEvent,
|
||||
initializeMocks,
|
||||
waitFor,
|
||||
} from '../testUtils';
|
||||
import React from 'react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import initializeStore from '../store';
|
||||
import { executeThunk } from '../utils';
|
||||
import { advancedSettingsMock } from './__mocks__';
|
||||
import { getCourseAdvancedSettingsApiUrl } from './data/api';
|
||||
@@ -25,22 +28,39 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
/>
|
||||
)));
|
||||
|
||||
const render = () => baseRender(
|
||||
<AdvancedSettings courseId={courseId} />,
|
||||
{ path: mockPathname },
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
pathname: mockPathname,
|
||||
}),
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<AdvancedSettings intl={injectIntl} courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<AdvancedSettings />', () => {
|
||||
beforeEach(() => {
|
||||
const mocks = initializeMocks();
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
|
||||
.reply(200, advancedSettingsMock);
|
||||
});
|
||||
it('should render without errors', async () => {
|
||||
const { getByText } = render();
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
|
||||
@@ -52,7 +72,7 @@ describe('<AdvancedSettings />', () => {
|
||||
});
|
||||
});
|
||||
it('should render setting element', async () => {
|
||||
const { getByText, queryByText } = render();
|
||||
const { getByText, queryByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const advancedModuleListTitle = getByText(/Advanced Module List/i);
|
||||
expect(advancedModuleListTitle).toBeInTheDocument();
|
||||
@@ -60,7 +80,7 @@ describe('<AdvancedSettings />', () => {
|
||||
});
|
||||
});
|
||||
it('should change to onСhange', async () => {
|
||||
const { getByLabelText } = render();
|
||||
const { getByLabelText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const textarea = getByLabelText(/Advanced Module List/i);
|
||||
expect(textarea).toBeInTheDocument();
|
||||
@@ -69,7 +89,7 @@ describe('<AdvancedSettings />', () => {
|
||||
});
|
||||
});
|
||||
it('should display a warning alert', async () => {
|
||||
const { getByLabelText, getByText } = render();
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const textarea = getByLabelText(/Advanced Module List/i);
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
@@ -80,7 +100,7 @@ describe('<AdvancedSettings />', () => {
|
||||
});
|
||||
});
|
||||
it('should display a tooltip on clicking on the icon', async () => {
|
||||
const { getByLabelText, getByText } = render();
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const button = getByLabelText(/Show help text/i);
|
||||
fireEvent.click(button);
|
||||
@@ -88,7 +108,7 @@ describe('<AdvancedSettings />', () => {
|
||||
});
|
||||
});
|
||||
it('should change deprecated button text ', async () => {
|
||||
const { getByText } = render();
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
|
||||
expect(showDeprecatedItemsBtn).toBeInTheDocument();
|
||||
@@ -98,7 +118,7 @@ describe('<AdvancedSettings />', () => {
|
||||
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
|
||||
});
|
||||
it('should reset to default value on click on Cancel button', async () => {
|
||||
const { getByLabelText, getByText } = render();
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
@@ -109,7 +129,7 @@ describe('<AdvancedSettings />', () => {
|
||||
expect(textarea.value).toBe('[]');
|
||||
});
|
||||
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
|
||||
const { getByLabelText, getByText } = render();
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
@@ -121,7 +141,7 @@ describe('<AdvancedSettings />', () => {
|
||||
expect(textarea.value).toBe('[3, 2, 1,');
|
||||
});
|
||||
it('should show success alert after save', async () => {
|
||||
const { getByLabelText, getByText } = render();
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as advancedSettingsMock } from './advancedSettings';
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import {
|
||||
camelCaseObject,
|
||||
getConfig,
|
||||
} from '@edx/frontend-platform';
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { camelCase } from 'lodash';
|
||||
import { convertObjectToSnakeCase } from '../../utils';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
@@ -19,19 +15,7 @@ const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/
|
||||
export async function getCourseAdvancedSettings(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
|
||||
const keepValues = {};
|
||||
Object.keys(data).forEach((key) => {
|
||||
keepValues[camelCase(key)] = { value: data[key].value };
|
||||
});
|
||||
const formattedData = {};
|
||||
const formattedCamelCaseData = camelCaseObject(data);
|
||||
Object.keys(formattedCamelCaseData).forEach((key) => {
|
||||
formattedData[key] = {
|
||||
...formattedCamelCaseData[key],
|
||||
value: keepValues[key]?.value,
|
||||
};
|
||||
});
|
||||
return formattedData;
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,19 +27,7 @@ export async function getCourseAdvancedSettings(courseId) {
|
||||
export async function updateCourseAdvancedSettings(courseId, settings) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
|
||||
const keepValues = {};
|
||||
Object.keys(data).forEach((key) => {
|
||||
keepValues[camelCase(key)] = { value: data[key].value };
|
||||
});
|
||||
const formattedData = {};
|
||||
const formattedCamelCaseData = camelCaseObject(data);
|
||||
Object.keys(formattedCamelCaseData).forEach((key) => {
|
||||
formattedData[key] = {
|
||||
...formattedCamelCaseData[key],
|
||||
value: keepValues[key]?.value,
|
||||
};
|
||||
});
|
||||
return formattedData;
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,17 +37,5 @@ export async function updateCourseAdvancedSettings(courseId, settings) {
|
||||
*/
|
||||
export async function getProctoringExamErrors(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
|
||||
const keepValues = {};
|
||||
Object.keys(data).forEach((key) => {
|
||||
keepValues[camelCase(key)] = { value: data[key].value };
|
||||
});
|
||||
const formattedData = {};
|
||||
const formattedCamelCaseData = camelCaseObject(data);
|
||||
Object.keys(formattedCamelCaseData).forEach((key) => {
|
||||
formattedData[key] = {
|
||||
...formattedCamelCaseData[key],
|
||||
value: keepValues[key]?.value,
|
||||
};
|
||||
});
|
||||
return formattedData;
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
getCourseAdvancedSettings,
|
||||
updateCourseAdvancedSettings,
|
||||
getProctoringExamErrors,
|
||||
} from './api';
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedHttpClient: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('courseSettings API', () => {
|
||||
const mockHttpClient = {
|
||||
get: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
|
||||
});
|
||||
|
||||
describe('getCourseAdvancedSettings', () => {
|
||||
it('should fetch and unformat course advanced settings', async () => {
|
||||
const fakeData = {
|
||||
key_snake_case: {
|
||||
display_name: 'To come camelCase',
|
||||
testCamelCase: 'This key must not be formatted',
|
||||
PascalCase: 'To come camelCase',
|
||||
'kebab-case': 'To come camelCase',
|
||||
UPPER_CASE: 'To come camelCase',
|
||||
lowercase: 'This key must not be formatted',
|
||||
UPPERCASE: 'To come lowercase',
|
||||
'Title Case': 'To come camelCase',
|
||||
'dot.case': 'To come camelCase',
|
||||
SCREAMING_SNAKE_CASE: 'To come camelCase',
|
||||
MixedCase: 'To come camelCase',
|
||||
'Train-Case': 'To come camelCase',
|
||||
nestedOption: {
|
||||
anotherOption: 'To come camelCase',
|
||||
},
|
||||
// value is an object with various cases
|
||||
// this contain must not be formatted to camelCase
|
||||
value: {
|
||||
snake_case: 'snake_case',
|
||||
camelCase: 'camelCase',
|
||||
PascalCase: 'PascalCase',
|
||||
'kebab-case': 'kebab-case',
|
||||
UPPER_CASE: 'UPPER_CASE',
|
||||
lowercase: 'lowercase',
|
||||
UPPERCASE: 'UPPERCASE',
|
||||
'Title Case': 'Title Case',
|
||||
'dot.case': 'dot.case',
|
||||
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
|
||||
MixedCase: 'MixedCase',
|
||||
'Train-Case': 'Train-Case',
|
||||
nestedOption: {
|
||||
anotherOption: 'nestedContent',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
keySnakeCase: {
|
||||
displayName: 'To come camelCase',
|
||||
testCamelCase: 'This key must not be formatted',
|
||||
pascalCase: 'To come camelCase',
|
||||
kebabCase: 'To come camelCase',
|
||||
upperCase: 'To come camelCase',
|
||||
lowercase: 'This key must not be formatted',
|
||||
uppercase: 'To come lowercase',
|
||||
titleCase: 'To come camelCase',
|
||||
dotCase: 'To come camelCase',
|
||||
screamingSnakeCase: 'To come camelCase',
|
||||
mixedCase: 'To come camelCase',
|
||||
trainCase: 'To come camelCase',
|
||||
nestedOption: {
|
||||
anotherOption: 'To come camelCase',
|
||||
},
|
||||
value: fakeData.key_snake_case.value,
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpClient.get.mockResolvedValue({ data: fakeData });
|
||||
|
||||
const result = await getCourseAdvancedSettings('course-v1:Test+T101+2024');
|
||||
expect(mockHttpClient.get).toHaveBeenCalledWith(
|
||||
`${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024?fetch_all=0`,
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCourseAdvancedSettings', () => {
|
||||
it('should update and unformat course advanced settings', async () => {
|
||||
const fakeData = {
|
||||
key_snake_case: {
|
||||
display_name: 'To come camelCase',
|
||||
testCamelCase: 'This key must not be formatted', // because already be camelCase
|
||||
PascalCase: 'To come camelCase',
|
||||
'kebab-case': 'To come camelCase',
|
||||
UPPER_CASE: 'To come camelCase',
|
||||
lowercase: 'This key must not be formatted', // because camelCase in lowercase not formatted
|
||||
UPPERCASE: 'To come lowercase', // because camelCase in UPPERCASE format to lowercase
|
||||
'Title Case': 'To come camelCase',
|
||||
'dot.case': 'To come camelCase',
|
||||
SCREAMING_SNAKE_CASE: 'To come camelCase',
|
||||
MixedCase: 'To come camelCase',
|
||||
'Train-Case': 'To come camelCase',
|
||||
nestedOption: {
|
||||
anotherOption: 'To come camelCase',
|
||||
},
|
||||
// value is an object with various cases
|
||||
// this contain must not be formatted to camelCase
|
||||
value: {
|
||||
snake_case: 'snake_case',
|
||||
camelCase: 'camelCase',
|
||||
PascalCase: 'PascalCase',
|
||||
'kebab-case': 'kebab-case',
|
||||
UPPER_CASE: 'UPPER_CASE',
|
||||
lowercase: 'lowercase',
|
||||
UPPERCASE: 'UPPERCASE',
|
||||
'Title Case': 'Title Case',
|
||||
'dot.case': 'dot.case',
|
||||
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
|
||||
MixedCase: 'MixedCase',
|
||||
'Train-Case': 'Train-Case',
|
||||
nestedOption: {
|
||||
anotherOption: 'nestedContent',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
keySnakeCase: {
|
||||
displayName: 'To come camelCase',
|
||||
testCamelCase: 'This key must not be formatted',
|
||||
pascalCase: 'To come camelCase',
|
||||
kebabCase: 'To come camelCase',
|
||||
upperCase: 'To come camelCase',
|
||||
lowercase: 'This key must not be formatted',
|
||||
uppercase: 'To come lowercase',
|
||||
titleCase: 'To come camelCase',
|
||||
dotCase: 'To come camelCase',
|
||||
screamingSnakeCase: 'To come camelCase',
|
||||
mixedCase: 'To come camelCase',
|
||||
trainCase: 'To come camelCase',
|
||||
nestedOption: {
|
||||
anotherOption: 'To come camelCase',
|
||||
},
|
||||
value: fakeData.key_snake_case.value,
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpClient.patch.mockResolvedValue({ data: fakeData });
|
||||
|
||||
const result = await updateCourseAdvancedSettings('course-v1:Test+T101+2024', {});
|
||||
expect(mockHttpClient.patch).toHaveBeenCalledWith(
|
||||
`${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024`,
|
||||
{},
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProctoringExamErrors', () => {
|
||||
it('should fetch proctoring errors and return unformat object', async () => {
|
||||
const fakeData = {
|
||||
key_snake_case: {
|
||||
display_name: 'To come camelCase',
|
||||
testCamelCase: 'This key must not be formatted',
|
||||
PascalCase: 'To come camelCase',
|
||||
'kebab-case': 'To come camelCase',
|
||||
UPPER_CASE: 'To come camelCase',
|
||||
lowercase: 'This key must not be formatted',
|
||||
UPPERCASE: 'To come lowercase',
|
||||
'Title Case': 'To come camelCase',
|
||||
'dot.case': 'To come camelCase',
|
||||
SCREAMING_SNAKE_CASE: 'To come camelCase',
|
||||
MixedCase: 'To come camelCase',
|
||||
'Train-Case': 'To come camelCase',
|
||||
nestedOption: {
|
||||
anotherOption: 'To come camelCase',
|
||||
},
|
||||
// value is an object with various cases
|
||||
// this contain must not be formatted to camelCase
|
||||
value: {
|
||||
snake_case: 'snake_case',
|
||||
camelCase: 'camelCase',
|
||||
PascalCase: 'PascalCase',
|
||||
'kebab-case': 'kebab-case',
|
||||
UPPER_CASE: 'UPPER_CASE',
|
||||
lowercase: 'lowercase',
|
||||
UPPERCASE: 'UPPERCASE',
|
||||
'Title Case': 'Title Case',
|
||||
'dot.case': 'dot.case',
|
||||
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
|
||||
MixedCase: 'MixedCase',
|
||||
'Train-Case': 'Train-Case',
|
||||
nestedOption: {
|
||||
anotherOption: 'nestedContent',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
keySnakeCase: {
|
||||
displayName: 'To come camelCase',
|
||||
testCamelCase: 'This key must not be formatted',
|
||||
pascalCase: 'To come camelCase',
|
||||
kebabCase: 'To come camelCase',
|
||||
upperCase: 'To come camelCase',
|
||||
lowercase: 'This key must not be formatted',
|
||||
uppercase: 'To come lowercase',
|
||||
titleCase: 'To come camelCase',
|
||||
dotCase: 'To come camelCase',
|
||||
screamingSnakeCase: 'To come camelCase',
|
||||
mixedCase: 'To come camelCase',
|
||||
trainCase: 'To come camelCase',
|
||||
nestedOption: {
|
||||
anotherOption: 'To come camelCase',
|
||||
},
|
||||
value: fakeData.key_snake_case.value,
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpClient.get.mockResolvedValue({ data: fakeData });
|
||||
|
||||
const result = await getProctoringExamErrors('course-v1:Test+T101+2024');
|
||||
expect(mockHttpClient.get).toHaveBeenCalledWith(
|
||||
`${process.env.STUDIO_BASE_URL}/api/contentstore/v1/proctoring_errors/course-v1:Test+T101+2024`,
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as AdvancedSettings } from './AdvancedSettings';
|
||||
|
||||
@@ -1,57 +1,55 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ActionRow, AlertModal, Button } from '@openedx/paragon';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ModalErrorListItem from './ModalErrorListItem';
|
||||
import messages from './messages';
|
||||
|
||||
const ModalError = ({
|
||||
isError, handleUndoChanges, showErrorModal, errorList, settingsData,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.modalErrorTitle)}
|
||||
isOpen={isError}
|
||||
variant="danger"
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => showErrorModal(!isError)}
|
||||
>
|
||||
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
|
||||
</Button>
|
||||
<Button onClick={handleUndoChanges}>
|
||||
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
intl, isError, handleUndoChanges, showErrorModal, errorList, settingsData,
|
||||
}) => (
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.modalErrorTitle)}
|
||||
isOpen={isError}
|
||||
variant="danger"
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => showErrorModal(!isError)}
|
||||
>
|
||||
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
|
||||
</Button>
|
||||
<Button onClick={handleUndoChanges}>
|
||||
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.modal.error.description"
|
||||
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.modal.error.description"
|
||||
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
|
||||
Please check the following validation feedbacks and reflect them in your course settings:"
|
||||
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
|
||||
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
|
||||
/>
|
||||
</p>
|
||||
<hr />
|
||||
<ul className="p-0">
|
||||
{errorList.map((settingName) => (
|
||||
<ModalErrorListItem
|
||||
key={settingName.key}
|
||||
settingName={settingName}
|
||||
settingsData={settingsData}
|
||||
/>
|
||||
</p>
|
||||
<hr />
|
||||
<ul className="p-0">
|
||||
{errorList.map((settingName) => (
|
||||
<ModalErrorListItem
|
||||
key={settingName.key}
|
||||
settingName={settingName}
|
||||
settingsData={settingsData}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</AlertModal>
|
||||
);
|
||||
};
|
||||
))}
|
||||
</ul>
|
||||
</AlertModal>
|
||||
);
|
||||
|
||||
ModalError.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
isError: PropTypes.bool.isRequired,
|
||||
handleUndoChanges: PropTypes.func.isRequired,
|
||||
showErrorModal: PropTypes.func.isRequired,
|
||||
@@ -62,4 +60,4 @@ ModalError.propTypes = {
|
||||
settingsData: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default ModalError;
|
||||
export default injectIntl(ModalError);
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 0 .625rem;
|
||||
z-index: var(--pgn-elevation-modal-zindex);
|
||||
z-index: $zindex-modal;
|
||||
}
|
||||
|
||||
.alert-proctoring-error {
|
||||
@@ -66,13 +66,13 @@
|
||||
.setting-sidebar-supplementary {
|
||||
.setting-sidebar-supplementary-about {
|
||||
.setting-sidebar-supplementary-about-title {
|
||||
font: normal var(--pgn-typography-font-weight-bold) 1.125rem/1.5rem var(--pgn-typography-font-family-base);
|
||||
color: var(--pgn-color-headings-base);
|
||||
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
|
||||
color: $headings-color;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.setting-sidebar-supplementary-about-descriptions {
|
||||
font: normal var(--pgn-typography-font-weight-normal) .875rem/1.5rem var(--pgn-typography-font-family-base);
|
||||
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
|
||||
color: $text-color-base;
|
||||
}
|
||||
}
|
||||
@@ -81,16 +81,16 @@
|
||||
list-style: none;
|
||||
|
||||
.setting-sidebar-supplementary-other-link {
|
||||
font: normal var(--pgn-typography-font-weight-normal) .875rem/1.5rem var(--pgn-typography-font-family-base);
|
||||
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
|
||||
line-height: 1.5rem;
|
||||
color: var(--pgn-color-info-500);
|
||||
color: $info-500;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-sidebar-supplementary-other-title {
|
||||
font: normal var(--pgn-typography-font-weight-bold) 1.125rem/1.5rem var(--pgn-typography-font-family-base);
|
||||
color: var(--pgn-color-headings-base);
|
||||
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
|
||||
color: $headings-color;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
margin-bottom: 5px;
|
||||
color: var(--pgn-color-danger-base);
|
||||
color: $danger;
|
||||
}
|
||||
|
||||
.modal-error-item-title {
|
||||
@@ -113,12 +113,12 @@
|
||||
|
||||
.modal-popup-content {
|
||||
max-width: 200px;
|
||||
color: var(--pgn-color-white);
|
||||
background-color: var(--pgn-color-black);
|
||||
color: $white;
|
||||
background-color: $black;
|
||||
filter: drop-shadow(0 2px 4px rgba(0 0 0 / .15));
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.pgn__modal-popup__arrow::after {
|
||||
border-top-color: var(--pgn-color-black);
|
||||
border-top-color: $black;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
$text-color-base: var(--pgn-color-gray-700);
|
||||
$text-color-base: $gray-700;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { InfoOutline, Warning } from '@openedx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { capitalize } from 'lodash';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
|
||||
import messages from './messages';
|
||||
@@ -25,12 +25,13 @@ const SettingCard = ({
|
||||
saveSettingsPrompt,
|
||||
isEditableState,
|
||||
setIsEditableState,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { deprecated, help, displayName } = settingData;
|
||||
const initialValue = JSON.stringify(settingData.value, null, 4);
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState<HTMLButtonElement | null>(null);
|
||||
const [target, setTarget] = useState(null);
|
||||
const [newValue, setNewValue] = useState(initialValue);
|
||||
|
||||
const handleSettingChange = (e) => {
|
||||
@@ -70,7 +71,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
|
||||
@@ -114,11 +115,12 @@ const SettingCard = ({
|
||||
};
|
||||
|
||||
SettingCard.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
settingData: PropTypes.shape({
|
||||
deprecated: PropTypes.bool,
|
||||
help: PropTypes.string,
|
||||
displayName: PropTypes.string,
|
||||
value: PropTypes.oneOfType([
|
||||
value: PropTypes.PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
PropTypes.number,
|
||||
@@ -135,4 +137,4 @@ SettingCard.propTypes = {
|
||||
setIsEditableState: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SettingCard;
|
||||
export default injectIntl(SettingCard);
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
@@ -21,12 +22,14 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
<textarea
|
||||
{...props}
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
)));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en">
|
||||
<SettingCard
|
||||
intl={{}}
|
||||
isOn
|
||||
name="settingName"
|
||||
setEdited={setEdited}
|
||||
@@ -55,6 +58,7 @@ describe('<SettingCard />', () => {
|
||||
const { getByText } = render(
|
||||
<IntlProvider locale="en">
|
||||
<SettingCard
|
||||
intl={{}}
|
||||
isOn
|
||||
name="settingName"
|
||||
setEdited={setEdited}
|
||||
@@ -75,19 +79,18 @@ describe('<SettingCard />', () => {
|
||||
expect(queryByText(messages.deprecated.defaultMessage)).toBeNull();
|
||||
});
|
||||
it('calls setEdited on blur', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByLabelText } = render(<RootWrapper />);
|
||||
const inputBox = getByLabelText(/Setting Name/i);
|
||||
fireEvent.focus(inputBox);
|
||||
await user.clear(inputBox);
|
||||
await user.type(inputBox, '3, 2, 1');
|
||||
userEvent.clear(inputBox);
|
||||
userEvent.type(inputBox, '3, 2, 1');
|
||||
await waitFor(() => {
|
||||
expect(inputBox).toHaveValue('3, 2, 1');
|
||||
});
|
||||
await user.tab(); // blur off of the input.
|
||||
await waitFor(() => {
|
||||
await (async () => {
|
||||
expect(setEdited).toHaveBeenCalled();
|
||||
expect(handleBlur).toHaveBeenCalled();
|
||||
});
|
||||
fireEvent.focusOut(inputBox);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
FormattedMessage,
|
||||
injectIntl,
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { HelpSidebar } from '../../generic/help-sidebar';
|
||||
import messages from './messages';
|
||||
|
||||
const SettingsSidebar = ({ courseId, proctoredExamSettingsUrl = '' }) => (
|
||||
const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => (
|
||||
<HelpSidebar
|
||||
courseId={courseId}
|
||||
proctoredExamSettingsUrl={proctoredExamSettingsUrl}
|
||||
showOtherSettings
|
||||
>
|
||||
<h4 className="help-sidebar-about-title">
|
||||
<FormattedMessage {...messages.about} />
|
||||
{intl.formatMessage(messages.about)}
|
||||
</h4>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
<FormattedMessage {...messages.aboutDescription1} />
|
||||
{intl.formatMessage(messages.aboutDescription1)}
|
||||
</p>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
<FormattedMessage {...messages.aboutDescription2} />
|
||||
{intl.formatMessage(messages.aboutDescription2)}
|
||||
</p>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
<FormattedMessage
|
||||
@@ -31,9 +34,14 @@ const SettingsSidebar = ({ courseId, proctoredExamSettingsUrl = '' }) => (
|
||||
</HelpSidebar>
|
||||
);
|
||||
|
||||
SettingsSidebar.defaultProps = {
|
||||
proctoredExamSettingsUrl: '',
|
||||
};
|
||||
|
||||
SettingsSidebar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
proctoredExamSettingsUrl: PropTypes.string,
|
||||
};
|
||||
|
||||
export default SettingsSidebar;
|
||||
export default injectIntl(SettingsSidebar);
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
// @ts-check
|
||||
import { initializeMocks, render } from '../../testUtils';
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import SettingsSidebar from './SettingsSidebar';
|
||||
import messages from './messages';
|
||||
|
||||
const courseId = 'course-123';
|
||||
let store;
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<SettingsSidebar intl={{ formatMessage: jest.fn() }} courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<SettingsSidebar />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
it('renders about and other sidebar titles correctly', () => {
|
||||
const { getByText } = render(<SettingsSidebar courseId={courseId} />);
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText(messages.about.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.other.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
it('renders about descriptions correctly', () => {
|
||||
const { getByText } = render(<SettingsSidebar courseId={courseId} />);
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const aboutThirtyDescription = getByText('When you enter strings as policy values, ensure that you use double quotation marks (“) around the string. Do not use single quotation marks (‘).');
|
||||
expect(getByText(messages.aboutDescription1.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.aboutDescription2.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
.form-group-custom {
|
||||
.pgn__form-label {
|
||||
font: normal var(--pgn-typography-font-weight-bold) .75rem/1.25rem var(--pgn-typography-font-family-base);
|
||||
color: var(--pgn-color-gray-500);
|
||||
font: normal $font-weight-bold .75rem/1.25rem $font-family-base;
|
||||
color: $gray-500;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.pgn__form-control-description,
|
||||
.pgn__form-text {
|
||||
font: normal var(--pgn-typography-font-weight-normal) .75rem/1.25rem var(--pgn-typography-font-family-base);
|
||||
color: var(--pgn-color-gray-500);
|
||||
font: normal $font-weight-normal .75rem/1.25rem $font-family-base;
|
||||
color: $gray-500;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
|
||||
.form-group-custom_isInvalid {
|
||||
input {
|
||||
border-color: var(--pgn-color-form-feedback-invalid);
|
||||
border-color: $form-feedback-invalid-color;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-error {
|
||||
color: var(--pgn-color-form-feedback-invalid);
|
||||
color: $form-feedback-invalid-color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,40 +34,40 @@
|
||||
.datepicker-custom-control {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: var(--pgn-typography-form-input-font-size-base);
|
||||
font-weight: var(--pgn-typography-form-input-font-weight);
|
||||
line-height: var(--pgn-typography-form-input-line-height-base);
|
||||
background: var(--pgn-color-form-input-bg-base);
|
||||
border-color: var(--pgn-color-form-input-border);
|
||||
border-width: var(--pgn-size-form-input-width-border);
|
||||
box-shadow: var(--pgn-elevation-form-input-base);
|
||||
border-radius: var(--pgn-size-form-input-radius-border-base);
|
||||
color: var(--pgn-color-form-input-base);
|
||||
padding: var(--pgn-spacing-form-input-padding-y-base) var(--pgn-spacing-form-input-padding-x-base);
|
||||
height: var(--pgn-size-form-input-height-base);
|
||||
font-size: $input-font-size;
|
||||
font-weight: $input-font-weight;
|
||||
line-height: $input-line-height;
|
||||
background: $input-bg;
|
||||
border-color: $input-border-color;
|
||||
border-width: $input-border-width;
|
||||
box-shadow: $input-box-shadow;
|
||||
border-radius: $input-border-radius;
|
||||
color: $input-color;
|
||||
padding: $input-padding-y $input-padding-x;
|
||||
height: $input-height;
|
||||
resize: none;
|
||||
|
||||
&:focus,
|
||||
:focus-visible {
|
||||
color: var(--pgn-color-form-input-focus-base);
|
||||
background-color: var(--pgn-color-form-input-bg-base);
|
||||
border-color: var(--pgn-color-form-input-focus-border);
|
||||
box-shadow: var(--pgn-elevation-form-input-focus);
|
||||
color: $input-focus-color;
|
||||
background-color: $input-bg;
|
||||
border-color: $input-focus-border-color;
|
||||
box-shadow: $input-focus-box-shadow;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--pgn-color-form-input-placeholder);
|
||||
color: $input-placeholder-color;
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker-custom-control_readonly {
|
||||
border-color: transparent;
|
||||
background: var(--pgn-color-form-input-bg-disabled);
|
||||
background: $input-disabled-bg;
|
||||
}
|
||||
|
||||
.datepicker-custom-control_isInvalid {
|
||||
border-color: var(--pgn-color-form-feedback-invalid);
|
||||
border-color: $form-feedback-invalid-color;
|
||||
}
|
||||
|
||||
.datepicker-custom-control-icon {
|
||||
@@ -76,10 +76,6 @@
|
||||
right: 1.188rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--pgn-color-black);
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
|
||||
.react-datepicker-popper {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.text-black {
|
||||
color: var(--pgn-color-black);
|
||||
color: $black;
|
||||
}
|
||||
|
||||
.h-200px {
|
||||
@@ -9,7 +9,3 @@
|
||||
.mw-300px {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.right-0 {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
$text-color-base: var(--pgn-color-gray-700);
|
||||
$text-color-base: $gray-700;
|
||||
$text-color-weak: #3E3E3C;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
export const CONTENT_LIBRARY_PERMISSIONS = {
|
||||
DELETE_LIBRARY: 'content_libraries.delete_library',
|
||||
MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags',
|
||||
VIEW_LIBRARY: 'content_libraries.view_library',
|
||||
|
||||
EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content',
|
||||
PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content',
|
||||
REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content',
|
||||
|
||||
CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection',
|
||||
EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection',
|
||||
DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection',
|
||||
|
||||
MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
|
||||
VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
PermissionValidationAnswer,
|
||||
PermissionValidationQuery,
|
||||
PermissionValidationRequestItem,
|
||||
PermissionValidationResponseItem,
|
||||
} from '@src/authz/types';
|
||||
import { getApiUrl } from './utils';
|
||||
|
||||
export const validateUserPermissions = async (
|
||||
query: PermissionValidationQuery,
|
||||
): Promise<PermissionValidationAnswer> => {
|
||||
// Convert the validations query object into an array for the API request
|
||||
const request: PermissionValidationRequestItem[] = Object.values(query);
|
||||
|
||||
const { data }: { data: PermissionValidationResponseItem[] } = await getAuthenticatedHttpClient().post(
|
||||
getApiUrl('/api/authz/v1/permissions/validate/me'),
|
||||
request,
|
||||
);
|
||||
|
||||
// Convert the API response back into the expected answer format
|
||||
const result: PermissionValidationAnswer = {};
|
||||
data.forEach((item: { action: string; scope?: string; allowed: boolean }) => {
|
||||
const key = Object.keys(query).find(
|
||||
(k) => query[k].action === item.action
|
||||
&& query[k].scope === item.scope,
|
||||
);
|
||||
if (key) {
|
||||
result[key] = item.allowed;
|
||||
}
|
||||
});
|
||||
|
||||
// Fill any missing keys with false
|
||||
Object.keys(query).forEach((key) => {
|
||||
if (!(key in result)) {
|
||||
result[key] = false;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -1,168 +0,0 @@
|
||||
import { act, ReactNode } from 'react';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { useUserPermissions } from './apiHooks';
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedHttpClient: jest.fn(),
|
||||
}));
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
const singlePermission = {
|
||||
canRead: {
|
||||
action: 'example.read',
|
||||
scope: 'lib:example-org:test-lib',
|
||||
},
|
||||
};
|
||||
|
||||
const mockValidSinglePermission = [
|
||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
|
||||
];
|
||||
|
||||
const mockInvalidSinglePermission = [
|
||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
|
||||
];
|
||||
|
||||
const mockEmptyPermissions = [
|
||||
// No permissions returned
|
||||
];
|
||||
|
||||
const multiplePermissions = {
|
||||
canRead: {
|
||||
action: 'example.read',
|
||||
scope: 'lib:example-org:test-lib',
|
||||
},
|
||||
canWrite: {
|
||||
action: 'example.write',
|
||||
scope: 'lib:example-org:test-lib',
|
||||
},
|
||||
};
|
||||
|
||||
const mockValidMultiplePermissions = [
|
||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
|
||||
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: true },
|
||||
];
|
||||
|
||||
const mockInvalidMultiplePermissions = [
|
||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
|
||||
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: false },
|
||||
];
|
||||
|
||||
describe('useUserPermissions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns allowed true when permission is valid', async () => {
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockResolvedValueOnce({ data: mockValidSinglePermission }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserPermissions(singlePermission), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current).toBeDefined());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
expect(result.current.data!.canRead).toBe(true);
|
||||
});
|
||||
|
||||
it('returns allowed false when permission is invalid', async () => {
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockResolvedValue({ data: mockInvalidSinglePermission }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserPermissions(singlePermission), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current).toBeDefined());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
expect(result.current.data!.canRead).toBe(false);
|
||||
});
|
||||
|
||||
it('returns allowed true when multiple permissions are valid', async () => {
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockResolvedValueOnce({ data: mockValidMultiplePermissions }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current).toBeDefined());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
expect(result.current.data!.canRead).toBe(true);
|
||||
expect(result.current.data!.canWrite).toBe(true);
|
||||
});
|
||||
|
||||
it('returns allowed false when multiple permissions are invalid', async () => {
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockResolvedValue({ data: mockInvalidMultiplePermissions }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current).toBeDefined());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
expect(result.current.data!.canRead).toBe(false);
|
||||
expect(result.current.data!.canWrite).toBe(false);
|
||||
});
|
||||
|
||||
it('returns allowed false when the permission is not included in the server response', async () => {
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockResolvedValue({ data: mockEmptyPermissions }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserPermissions(singlePermission), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current).toBeDefined());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
expect(result.current.data!.canRead).toBe(false);
|
||||
});
|
||||
|
||||
it('handles error when the API call fails', async () => {
|
||||
const mockError = new Error('API Error');
|
||||
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockRejectedValue(new Error('API Error')),
|
||||
});
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
renderHook(() => useUserPermissions(singlePermission), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
expect(error).toEqual(mockError); // Check for the expected error
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
|
||||
import { validateUserPermissions } from './api';
|
||||
|
||||
const adminConsoleQueryKeys = {
|
||||
all: ['authz'],
|
||||
permissions: (permissions: PermissionValidationQuery) => [...adminConsoleQueryKeys.all, 'validatePermissions', permissions] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* React Query hook to validate if the current user has permissions over a certain object in the instance.
|
||||
* It helps to:
|
||||
* - Determine whether the current user can access certain object.
|
||||
* - Provide role-based rendering logic for UI components.
|
||||
*
|
||||
* @param permissions - A key/value map of objects and actions to validate.
|
||||
* The key is an arbitrary string to identify the permission check,
|
||||
* and the value is an object containing the action and optional scope.
|
||||
*
|
||||
* @example
|
||||
* const { isLoading, data } = useUserPermissions({
|
||||
* canRead: {
|
||||
* action: "content_libraries.view_library",
|
||||
* scope: "lib:OpenedX:CSPROB"
|
||||
* }
|
||||
* });
|
||||
* if (data.canRead) { ... }
|
||||
*
|
||||
*/
|
||||
export const useUserPermissions = (
|
||||
permissions: PermissionValidationQuery,
|
||||
) => useQuery<PermissionValidationAnswer, Error>({
|
||||
queryKey: adminConsoleQueryKeys.permissions(permissions),
|
||||
queryFn: () => validateUserPermissions(permissions),
|
||||
retry: false,
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`;
|
||||
export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`;
|
||||
@@ -1,16 +0,0 @@
|
||||
export interface PermissionValidationRequestItem {
|
||||
action: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface PermissionValidationResponseItem extends PermissionValidationRequestItem {
|
||||
allowed: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionValidationQuery {
|
||||
[permissionKey: string]: PermissionValidationRequestItem;
|
||||
}
|
||||
|
||||
export interface PermissionValidationAnswer {
|
||||
[permissionKey: string]: boolean;
|
||||
}
|
||||
@@ -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,175 +0,0 @@
|
||||
// @ts-check
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { initializeMocks, render, waitFor } from '../testUtils';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { executeThunk } from '../utils';
|
||||
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(<Certificates courseId={courseId} {...props} />);
|
||||
|
||||
describe('Certificates', () => {
|
||||
beforeEach(async () => {
|
||||
const mocks = initializeMocks();
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
});
|
||||
|
||||
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 user = userEvent.setup();
|
||||
|
||||
const { queryByTestId, getByTestId, getByRole } = renderComponent();
|
||||
|
||||
await waitFor(async () => {
|
||||
const addCertificateButton = getByRole('button', { name: messages.setupCertificateBtn.defaultMessage });
|
||||
await user.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 user = userEvent.setup();
|
||||
|
||||
const { queryByTestId, getByTestId, getAllByLabelText } = renderComponent();
|
||||
|
||||
await waitFor(async () => {
|
||||
const editCertificateButton = getAllByLabelText(messages.editTooltip.defaultMessage)[0];
|
||||
await user.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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user