Compare commits
3 Commits
test_hyper
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7424b60a90 | ||
|
|
1c0e6fd4b5 | ||
|
|
bc3faa4105 |
22
.env
@@ -1,4 +1,3 @@
|
||||
APP_ID='authoring'
|
||||
NODE_ENV='production'
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
BASE_URL=''
|
||||
@@ -17,33 +16,16 @@ LOGO_URL=''
|
||||
LOGO_WHITE_URL=''
|
||||
LOGOUT_URL=null
|
||||
MARKETING_SITE_BASE_URL=''
|
||||
TERMS_OF_SERVICE_URL=''
|
||||
PRIVACY_POLICY_URL=''
|
||||
ORDER_HISTORY_URL=''
|
||||
PUBLISHER_BASE_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=''
|
||||
STUDIO_SHORT_NAME='Studio'
|
||||
SUPPORT_EMAIL=''
|
||||
SUPPORT_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
ENABLE_ACCESSIBILITY_PAGE=false
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
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_NEW_EDITOR_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=false
|
||||
INVITE_STUDENTS_EMAIL_TO=''
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=''
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
# TODO: Missing support for ORA2
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
APP_ID='authoring'
|
||||
NODE_ENV='development'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2001'
|
||||
BASE_URL='localhost:2001'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL=
|
||||
@@ -17,35 +16,18 @@ LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
TERMS_OF_SERVICE_URL=
|
||||
PRIVACY_POLICY_URL=
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
PORT=2001
|
||||
PUBLISHER_BASE_URL=
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='Your Plaform Name Here'
|
||||
SITE_NAME='edX'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
STUDIO_SHORT_NAME='Studio'
|
||||
SUPPORT_EMAIL=
|
||||
SUPPORT_EMAIL='support@example.com'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_ACCESSIBILITY_PAGE=false
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=true
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
|
||||
18
.env.test
@@ -1,6 +1,5 @@
|
||||
APP_ID='authoring'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2001'
|
||||
BASE_URL='localhost:2001'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
@@ -23,22 +22,11 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
STUDIO_SHORT_NAME='Studio'
|
||||
SUPPORT_EMAIL='support@example.com'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_UNIT_PAGE=true
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
# TODO: Missing support for ORA2
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,openassessment"
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
coverage/*
|
||||
dist/
|
||||
node_modules/
|
||||
jest.config.js
|
||||
env.config.jsx
|
||||
example.env.config.jsx
|
||||
jest.config.js
|
||||
24
.eslintrc.js
@@ -1,9 +1,8 @@
|
||||
const path = require('path');
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig(
|
||||
'eslint',
|
||||
'eslint',
|
||||
{
|
||||
rules: {
|
||||
'jsx-a11y/label-has-associated-control': [2, {
|
||||
@@ -11,25 +10,8 @@ module.exports = createConfig(
|
||||
}],
|
||||
'template-curly-spacing': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
indent: 'off',
|
||||
'no-restricted-exports': 'off',
|
||||
// There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers
|
||||
'no-restricted-syntax': 'off',
|
||||
},
|
||||
settings: {
|
||||
// Import URLs should be resolved using aliases
|
||||
'import/resolver': {
|
||||
webpack: {
|
||||
config: path.resolve(__dirname, 'webpack.dev.config.js'),
|
||||
},
|
||||
},
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['plugins/**/*.test.jsx'],
|
||||
rules: {
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
7
.github/dependabot.yml
vendored
@@ -1,7 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Adding new check for github-actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
27
.github/pull_request_template.md
vendored
@@ -1,27 +0,0 @@
|
||||
## Description
|
||||
|
||||
Describe what this pull request changes, and why. Include implications for people using this change.
|
||||
Design decisions and their rationales should be documented in the repo (docstring / ADR), per
|
||||
|
||||
Useful information to include:
|
||||
- Which edX user roles will this change impact? Common user roles are "Learner", "Course Author",
|
||||
"Developer", and "Operator".
|
||||
- Include screenshots for changes to the UI (ideally, both "before" and "after" screenshots, if applicable).
|
||||
- Provide links to the description of corresponding configuration changes. Remember to correctly annotate these
|
||||
changes.
|
||||
|
||||
## Supporting information
|
||||
|
||||
Link to other information about the change, such as Jira issues, GitHub issues, or Discourse discussions.
|
||||
Be sure to check they are publicly readable, or if not, repeat the information here.
|
||||
|
||||
## Testing instructions
|
||||
|
||||
Please provide detailed step-by-step instructions for testing this change.
|
||||
|
||||
|
||||
## Other information
|
||||
|
||||
Include anything else that will help reviewers and consumers understand the change.
|
||||
- Does this change depend on other changes elsewhere?
|
||||
- Any special concerns or limitations? For example: deprecations, migrations, security, or accessibility.
|
||||
26
.github/workflows/validate.yml
vendored
@@ -9,29 +9,15 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
- run: make validate.ci
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: code-coverage-report
|
||||
path: coverage/*.*
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: code-coverage-report
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
10
.gitignore
vendored
@@ -1,7 +1,6 @@
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
.run
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
@@ -21,12 +20,3 @@ temp/babel-plugin-react-intl
|
||||
/temp
|
||||
/.vscode
|
||||
/module.config.js
|
||||
|
||||
# Local environment overrides
|
||||
.env.private
|
||||
|
||||
# Messages .json files fetched by atlas
|
||||
src/i18n/messages/
|
||||
|
||||
# environment js config
|
||||
env.config.jsx
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"extends": ["@edx/stylelint-config-edx"],
|
||||
"rules": {
|
||||
"selector-pseudo-class-no-unknown": [true, {
|
||||
"ignorePseudoClasses": ["export"]
|
||||
}],
|
||||
"unit-no-unknown": [true, {
|
||||
"ignoreUnits": ["\\.5"]
|
||||
}],
|
||||
"property-no-vendor-prefix": [true, {
|
||||
"ignoreProperties": ["animation", "filter", "transform", "transition"]
|
||||
}],
|
||||
"value-no-vendor-prefix": [true, {
|
||||
"ignoreValues": ["fill-available"]
|
||||
}],
|
||||
"function-no-unknown": null,
|
||||
"number-leading-zero": "never",
|
||||
"no-descending-specificity": null,
|
||||
"selector-class-pattern": null,
|
||||
"scss/no-global-function-names": null,
|
||||
"color-hex-case": "upper",
|
||||
"color-hex-length": "long",
|
||||
"scss/dollar-variable-empty-line-before": null,
|
||||
"scss/dollar-variable-colon-space-after": "at-least-one-space",
|
||||
"at-rule-no-unknown": null,
|
||||
"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"]
|
||||
}],
|
||||
"alpha-value-notation": "number"
|
||||
}
|
||||
}
|
||||
9
.tx/config
Normal file
@@ -0,0 +1,9 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[o:open-edx:p:edx-platform:r:frontend-app-course-authoring]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# The following users are the maintainers of all frontend-app-authoring files
|
||||
* @openedx/2u-tnl
|
||||
36
Makefile
@@ -1,17 +1,20 @@
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
transifex_resource = frontend-app-course-authoring
|
||||
export TRANSIFEX_RESOURCE = ${transifex_resource}
|
||||
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA"
|
||||
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
transifex_input = $(i18n)/transifex_input.json
|
||||
|
||||
# This directory must match .babelrc .
|
||||
transifex_temp = ./temp/babel-plugin-formatjs
|
||||
transifex_temp = ./temp/babel-plugin-react-intl
|
||||
|
||||
precommit:
|
||||
npm run lint
|
||||
npm audit
|
||||
|
||||
requirements:
|
||||
npm ci
|
||||
npm install
|
||||
|
||||
i18n.extract:
|
||||
# Pulling display strings from .jsx files into .json files...
|
||||
@@ -29,18 +32,20 @@ detect_changed_source_translations:
|
||||
# Checking for changed translations...
|
||||
git diff --exit-code $(i18n)
|
||||
|
||||
pull_translations:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull $(ATLAS_OPTIONS) \
|
||||
translations/frontend-component-ai-translations/src/i18n/messages:frontend-component-ai-translations \
|
||||
translations/frontend-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
|
||||
# Pushes translations to Transifex. You must run make extract_translations first.
|
||||
push_translations:
|
||||
# Pushing strings to Transifex...
|
||||
tx push -s
|
||||
# Fetching hashes from Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
|
||||
# Writing out comments to file...
|
||||
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
$(intl_imports) frontend-component-ai-translations frontend-platform paragon frontend-component-footer frontend-app-course-authoring
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
@@ -52,8 +57,7 @@ validate:
|
||||
make validate-no-uncommitted-package-lock-changes
|
||||
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
|
||||
|
||||
346
README.rst
@@ -1,106 +1,20 @@
|
||||
frontend-app-authoring
|
||||
######################
|
||||
|Build Status| |Codecov| |license|
|
||||
|
||||
|license-badge| |status-badge| |codecov-badge|
|
||||
#############################
|
||||
frontend-app-course-authoring
|
||||
#############################
|
||||
|
||||
Please tag `@edx/teaching-and-learning <https://github.com/orgs/edx/teams/teaching-and-learning>`_ on any PRs or issues. Thanks.
|
||||
|
||||
Purpose
|
||||
*******
|
||||
|
||||
This implements most of the frontend for **Open edX Studio**, allowing authors to create and edit courses, libraries, and their learning components.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Getting Started
|
||||
************
|
||||
Introduction
|
||||
************
|
||||
|
||||
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.
|
||||
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
||||
|
||||
|
||||
Cloning and Setup
|
||||
=================
|
||||
|
||||
1. Clone your new repo:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git clone https://github.com/openedx/frontend-app-authoring.git
|
||||
|
||||
2. Use the version of Node specified in the ``.nvmrc`` file.
|
||||
|
||||
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>`_.
|
||||
|
||||
3. Stop the Tutor devstack, if it's running: ``tutor dev stop``
|
||||
|
||||
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:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
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:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
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>`__.
|
||||
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
|
||||
|
||||
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.
|
||||
|
||||
********
|
||||
Features
|
||||
********
|
||||
|
||||
@@ -109,12 +23,14 @@ Feature: Pages and Resources Studio Tab
|
||||
|
||||
Enables a "Pages & Resources" menu item in Studio, under the "Content" menu.
|
||||
|
||||
.. image:: ./docs/readme-images/feature-pages-resources.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
The following are requirements for this feature to function correctly:
|
||||
The following are external requirements for this feature to function correctly:
|
||||
|
||||
* ``edx-platform`` Django settings:
|
||||
|
||||
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
@@ -163,34 +79,46 @@ For a particular course, this page allows one to:
|
||||
Feature: New React XBlock Editors
|
||||
=================================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-problem-editor.png
|
||||
|
||||
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`` Django settings:
|
||||
|
||||
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
|
||||
|
||||
* ``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
|
||||
|
||||
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
|
||||
==================================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-proctored-exams.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Django settings:
|
||||
|
||||
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
|
||||
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
|
||||
|
||||
* ``edx-platform`` Feature flags:
|
||||
@@ -216,111 +144,34 @@ In Studio, a new item ("Proctored Exam Settings") is added to "Other Course Sett
|
||||
* Select a proctoring provider
|
||||
* Enable automatic creation of Zendesk tickets for "suspicious" proctored exam attempts
|
||||
|
||||
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
|
||||
==========================
|
||||
|
||||
.. 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
|
||||
==========================
|
||||
|
||||
.. 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
|
||||
================================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-tagging-taxonomy-pages.png
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``new_studio_mfe.use_tagging_taxonomy_list_page``: this feature flag must be enabled.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Installation and Startup
|
||||
========================
|
||||
|
||||
1. Clone the repo:
|
||||
|
||||
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
|
||||
|
||||
2. Install npm dependencies:
|
||||
|
||||
``cd frontend-app-course-authoring && npm install``
|
||||
|
||||
3. Start the dev server:
|
||||
|
||||
``npm start``
|
||||
|
||||
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
|
||||
|
||||
If your devstack includes the default Demo course, you can visit the following URLs to see content:
|
||||
|
||||
- `Pages and Resources <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources>`_
|
||||
- `Proctored Exam Settings <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/proctored-exam-settings>`_
|
||||
- `Pages and Resources <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources>`_ (work in progress)
|
||||
|
||||
Troubleshooting
|
||||
========================
|
||||
@@ -331,7 +182,7 @@ Troubleshooting
|
||||
If there is still an error, look for "no package [...] found" in the error message and install missing package via brew.
|
||||
(https://github.com/Automattic/node-canvas/issues/1733)
|
||||
|
||||
|
||||
*********
|
||||
Deploying
|
||||
*********
|
||||
|
||||
@@ -344,94 +195,5 @@ 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
|
||||
|
||||
Internationalization
|
||||
====================
|
||||
|
||||
Please see refer to the `frontend-platform i18n howto`_ for documentation on
|
||||
internationalization.
|
||||
|
||||
.. _frontend-platform i18n howto: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
|
||||
|
||||
|
||||
Getting Help
|
||||
************
|
||||
|
||||
If you're having trouble, we have discussion forums at
|
||||
https://discuss.openedx.org where you can connect with others in the community.
|
||||
|
||||
Our real-time conversations are on Slack. You can request a `Slack
|
||||
invitation`_, then join our `community Slack workspace`_. Because this is a
|
||||
frontend repository, the best place to discuss it would be in the `#wg-frontend
|
||||
channel`_.
|
||||
|
||||
For anything non-trivial, the best path is to open an issue in this repository
|
||||
with as many details about the issue you are facing as you can provide.
|
||||
|
||||
https://github.com/openedx/frontend-app-course-authoring/issues
|
||||
|
||||
For more information about these options, see the `Getting Help`_ page.
|
||||
|
||||
.. _Slack invitation: https://openedx.org/slack
|
||||
.. _community Slack workspace: https://openedx.slack.com/
|
||||
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
|
||||
.. _Getting Help: https://openedx.org/community/connect
|
||||
|
||||
|
||||
License
|
||||
*******
|
||||
|
||||
The code in this repository is licensed under the AGPLv3 unless otherwise
|
||||
noted.
|
||||
|
||||
Please see `LICENSE <LICENSE>`_ for details.
|
||||
|
||||
|
||||
Contributing
|
||||
************
|
||||
|
||||
Contributions are very welcome. Please read `How To Contribute`_ for details.
|
||||
|
||||
.. _How To Contribute: https://openedx.org/r/how-to-contribute
|
||||
|
||||
This project is currently accepting all types of contributions, bug fixes,
|
||||
security fixes, maintenance work, or new features. However, please make sure
|
||||
to have a discussion about your new feature idea with the maintainers prior to
|
||||
beginning development to maximize the chances of your change being accepted.
|
||||
You can start a conversation by creating a new issue on this repo summarizing
|
||||
your idea.
|
||||
|
||||
|
||||
The Open edX Code of Conduct
|
||||
****************************
|
||||
|
||||
All community members are expected to follow the `Open edX Code of Conduct`_.
|
||||
|
||||
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
|
||||
|
||||
People
|
||||
******
|
||||
|
||||
The assigned maintainers for this component and other project details may be
|
||||
found in `Backstage`_. Backstage pulls this data from the ``catalog-info.yaml``
|
||||
file in this repo.
|
||||
|
||||
.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/frontend-app-course-authoring
|
||||
|
||||
|
||||
Reporting Security Issues
|
||||
*************************
|
||||
|
||||
Please do not report security issues in public, and email security@openedx.org instead.
|
||||
|
||||
.. |license-badge| image:: https://img.shields.io/github/license/openedx/frontend-app-course-authoring.svg
|
||||
:target: https://github.com/openedx/frontend-app-course-authoring/blob/master/LICENSE
|
||||
:alt: License
|
||||
|
||||
.. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen
|
||||
|
||||
.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-course-authoring/coverage.svg?branch=master
|
||||
:target: https://codecov.io/github/openedx/frontend-app-course-authoring?branch=master
|
||||
:alt: Codecov
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
|
||||
:target: @edx/frontend-app-course-authoring
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# This file records information about this repo. Its use is described in OEP-55:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
|
||||
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: 'frontend-app-authoring'
|
||||
description: "The frontend (MFE) for Open edX Authoring (aka Studio)"
|
||||
links:
|
||||
- url: "https://github.com/openedx/frontend-app-authoring"
|
||||
title: "Frontend app authoring"
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
openedx.org/release: "master"
|
||||
spec:
|
||||
owner: group:2u-tnl
|
||||
type: 'website'
|
||||
lifecycle: 'production'
|
||||
@@ -8,6 +8,3 @@ coverage:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 0%
|
||||
ignore:
|
||||
- "src/grading-settings/grading-scale/react-ranger.js"
|
||||
- "src/index.js"
|
||||
|
||||
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 64 KiB |
@@ -1,24 +0,0 @@
|
||||
import WholeCourseTranslation from '@edx/course-app-translation-plugin';
|
||||
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
// Load environment variables from .env file
|
||||
const config = {
|
||||
...process.env,
|
||||
pluginSlots: {
|
||||
additional_course_plugin: {
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'whole-course-translation-plugin',
|
||||
type: DIRECT_PLUGIN,
|
||||
priority: 1,
|
||||
RenderWidget: WholeCourseTranslation,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,19 +1,17 @@
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFilesAfterEnv: [
|
||||
'jest-expect-message',
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
'src/setupTest.js',
|
||||
'src/i18n',
|
||||
],
|
||||
snapshotSerializers: [
|
||||
'enzyme-to-json/serializer',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^lodash-es$': 'lodash',
|
||||
'^CourseAuthoring/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
modulePathIgnorePatterns: [
|
||||
'/src/pages-and-resources/utils.test.jsx',
|
||||
],
|
||||
});
|
||||
|
||||
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
|
||||
26176
package-lock.json
generated
143
package.json
@@ -1,124 +1,83 @@
|
||||
{
|
||||
"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",
|
||||
"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 .",
|
||||
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"snapshot": "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"
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run lint"
|
||||
}
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/openedx/frontend-app-authoring#readme",
|
||||
"homepage": "https://github.com/openedx/frontend-app-course-authoring#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/openedx/frontend-app-authoring/issues"
|
||||
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/lang-xml": "^6.0.0",
|
||||
"@codemirror/lint": "^6.2.1",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-component-footer": "^14.3.0",
|
||||
"@edx/frontend-component-header": "^6.2.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^7.2.0",
|
||||
"@edx/frontend-platform": "^8.3.1",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
||||
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||
"@openedx-plugins/course-app-live": "file:plugins/course-apps/live",
|
||||
"@openedx-plugins/course-app-ora_settings": "file:plugins/course-apps/ora_settings",
|
||||
"@openedx-plugins/course-app-proctoring": "file:plugins/course-apps/proctoring",
|
||||
"@openedx-plugins/course-app-progress": "file:plugins/course-apps/progress",
|
||||
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
|
||||
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
|
||||
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
|
||||
"@openedx/frontend-build": "^14.3.3",
|
||||
"@openedx/frontend-plugin-framework": "^1.6.0",
|
||||
"@openedx/frontend-slot-footer": "^1.2.0",
|
||||
"@openedx/paragon": "^22.16.0",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tanstack/react-query": "4.36.1",
|
||||
"@tinymce/tinymce-react": "^3.14.0",
|
||||
"classnames": "2.5.1",
|
||||
"codemirror": "^6.0.0",
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "11.1.1",
|
||||
"@edx/frontend-lib-content-components": "^1.131.0",
|
||||
"@edx/frontend-platform": "2.5.1",
|
||||
"@edx/paragon": "^20.38.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.28",
|
||||
"@fortawesome/free-brands-svg-icons": "5.11.2",
|
||||
"@fortawesome/free-regular-svg-icons": "5.11.2",
|
||||
"@fortawesome/free-solid-svg-icons": "5.11.2",
|
||||
"@fortawesome/react-fontawesome": "0.1.9",
|
||||
"@reduxjs/toolkit": "1.5.0",
|
||||
"classnames": "2.2.6",
|
||||
"core-js": "3.8.1",
|
||||
"email-validator": "2.0.4",
|
||||
"fast-xml-parser": "^4.0.10",
|
||||
"file-saver": "^2.0.5",
|
||||
"formik": "2.4.6",
|
||||
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"formik": "2.2.6",
|
||||
"lodash": "4.17.21",
|
||||
"meilisearch": "^0.41.0",
|
||||
"moment": "2.30.1",
|
||||
"moment-shortformat": "^2.1.0",
|
||||
"npm": "^10.8.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-datepicker": "^4.13.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"moment": "2.29.2",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-onclickoutside": "^6.13.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-responsive": "9.0.2",
|
||||
"react-router": "6.27.0",
|
||||
"react-router-dom": "6.27.0",
|
||||
"react-select": "5.8.0",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-transition-group": "4.4.5",
|
||||
"react-redux": "7.1.3",
|
||||
"react-responsive": "8.1.0",
|
||||
"react-router": "5.1.2",
|
||||
"react-router-dom": "5.1.2",
|
||||
"react-transition-group": "4.4.1",
|
||||
"redux": "4.0.5",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.4.1",
|
||||
"reselect": "^4.1.5",
|
||||
"start": "^5.1.0",
|
||||
"tinymce": "^5.10.4",
|
||||
"universal-cookie": "^4.0.4",
|
||||
"regenerator-runtime": "0.13.7",
|
||||
"uuid": "^3.4.0",
|
||||
"xmlchecker": "^0.1.0",
|
||||
"yup": "0.31.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/react-unit-test-utils": "^4.0.0",
|
||||
"@edx/stylelint-config-edx": "2.3.3",
|
||||
"@edx/typescript-config": "^1.0.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@edx/browserslist-config": "1.0.0",
|
||||
"@edx/frontend-build": "12.8.38",
|
||||
"@edx/reactifex": "^1.0.3",
|
||||
"@testing-library/jest-dom": "5.16.4",
|
||||
"@testing-library/react": "12.1.1",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.8",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"redux-mock-store": "^1.5.4"
|
||||
"axios-mock-adapter": "1.20.0",
|
||||
"enzyme": "3.11.0",
|
||||
"enzyme-adapter-react-16": "1.15.6",
|
||||
"enzyme-to-json": "^3.6.2",
|
||||
"glob": "7.1.6",
|
||||
"husky": "3.1.0",
|
||||
"react-test-renderer": "16.9.0",
|
||||
"reactifex": "1.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* Settings widget for the "calculator" Course App.
|
||||
* @param {{onClose: () => void}} props
|
||||
*/
|
||||
const CalculatorSettings = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId="calculator"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableCalculatorHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableCalculatorLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableCalculatorLink)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
CalculatorSettings.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default CalculatorSettings;
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-calculator",
|
||||
"version": "0.1.0",
|
||||
"description": "Calculator configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* Settings widget for the "edxnotes" Course App.
|
||||
* @param {{onClose: () => void}} props
|
||||
*/
|
||||
const NotesSettings = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId="edxnotes"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableNotesHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableNotesLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableNotesLink)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
NotesSettings.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default NotesSettings;
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-edxnotes",
|
||||
"version": "0.1.0",
|
||||
"description": "edxnotes configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@openedx/paragon';
|
||||
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const LearningAssistantSettings = ({ onClose }) => {
|
||||
const appId = 'learning_assistant';
|
||||
const appInfo = useModel('courseApps', appId);
|
||||
const intl = useIntl();
|
||||
|
||||
// We need to render more than one link, so we use the bodyChildren prop.
|
||||
const bodyChildren = (
|
||||
appInfo?.documentationLinks?.learnMoreOpenaiDataPrivacy && appInfo?.documentationLinks?.learnMoreOpenai
|
||||
? (
|
||||
<div className="d-flex flex-column">
|
||||
{appInfo.documentationLinks?.learnMoreOpenaiDataPrivacy && (
|
||||
<Hyperlink
|
||||
className="text-primary-500"
|
||||
destination={appInfo.documentationLinks.learnMoreOpenaiDataPrivacy}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{intl.formatMessage(messages.learningAssistantOpenAIDataPrivacyLink)}
|
||||
</Hyperlink>
|
||||
)}
|
||||
{appInfo.documentationLinks?.learnMoreOpenai && (
|
||||
<Hyperlink
|
||||
className="text-primary-500"
|
||||
destination={appInfo.documentationLinks.learnMoreOpenai}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{intl.formatMessage(messages.learningAssistantOpenAILink)}
|
||||
</Hyperlink>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId={appId}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableLearningAssistantHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableLearningAssistantLabel)}
|
||||
bodyChildren={bodyChildren}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
LearningAssistantSettings.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default LearningAssistantSettings;
|
||||
@@ -1,59 +0,0 @@
|
||||
import React from 'react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
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: {
|
||||
courseApps: {
|
||||
learning_assistant:
|
||||
{
|
||||
id: 'learning_assistant',
|
||||
enabled: true,
|
||||
name: 'Learning Assistant',
|
||||
description: 'Learning Assistant description',
|
||||
allowedOperations: {
|
||||
configure: false,
|
||||
enable: true,
|
||||
},
|
||||
documentationLinks: {
|
||||
learnMoreOpenaiDataPrivacy: 'www.example.com/learn-more-data-privacy',
|
||||
learnMoreOpenai: 'www.example.com/learn-more',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pagesAndResources: {
|
||||
loadingStatus: RequestStatus.SUCCESSFUL,
|
||||
},
|
||||
};
|
||||
|
||||
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 '
|
||||
+ 'of the AI-powered experience for use by edX to improve the performance of the tool.';
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Configure Learning Assistant' })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText(toggleDescription)).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('Learn more about how OpenAI handles data')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('Learn more about OpenAI API data privacy')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.pages-resources.learning-assistant.heading',
|
||||
defaultMessage: 'Configure Learning Assistant',
|
||||
},
|
||||
enableLearningAssistantLabel: {
|
||||
id: 'course-authoring.pages-resources.learning_assistant.enable-learning-assistant.label',
|
||||
defaultMessage: 'Learning Assistant',
|
||||
},
|
||||
enableLearningAssistantHelp: {
|
||||
id: 'course-authoring.pages-resources.learning_assistant.enable-learning-assistant.help',
|
||||
defaultMessage: `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 of the AI-powered experience for
|
||||
use by edX to improve the performance of the tool.`,
|
||||
},
|
||||
learningAssistantOpenAILink: {
|
||||
id: 'course-authoring.pages-resources.learning_assistant.open-ai.link',
|
||||
defaultMessage: 'Learn more about how OpenAI handles data',
|
||||
},
|
||||
learningAssistantOpenAIDataPrivacyLink: {
|
||||
id: 'course-authoring.pages-resources.learning_assistant.open-ai.data-privacy.link',
|
||||
defaultMessage: 'Learn more about OpenAI API data privacy',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-learning_assistant",
|
||||
"version": "0.1.0",
|
||||
"description": "Learning Assistant configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-live",
|
||||
"version": "0.1.0",
|
||||
"description": "Live course configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"@reduxjs/toolkit": "*",
|
||||
"lodash": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"react-redux": "*",
|
||||
"react-router-dom": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
import { Info } from '@openedx/paragon/icons';
|
||||
import { updateModel, useModel } from 'CourseAuthoring/generic/model-store';
|
||||
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||
import Loading from 'CourseAuthoring/generic/Loading';
|
||||
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
||||
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
|
||||
import { useAppSetting, useIsMobile } from 'CourseAuthoring/utils';
|
||||
import { getLoadingStatus, getSavingStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
|
||||
import { updateSavingStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const ORASettings = ({ onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { formatMessage } = useIntl();
|
||||
const alertRef = useRef(null);
|
||||
const updateSettingsRequestStatus = useSelector(getSavingStatus);
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
const isMobile = useIsMobile();
|
||||
const modalVariant = isMobile ? 'dark' : 'default';
|
||||
const appId = 'ora_settings';
|
||||
const appInfo = useModel('courseApps', appId);
|
||||
|
||||
const [enableFlexiblePeerGrade, saveSetting] = useAppSetting(
|
||||
'forceOnFlexiblePeerOpenassessments',
|
||||
);
|
||||
const initialFormValues = { enableFlexiblePeerGrade };
|
||||
|
||||
const [formValues, setFormValues] = useState(initialFormValues);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
|
||||
const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default';
|
||||
const handleSettingsSave = (values) => saveSetting(values.enableFlexiblePeerGrade);
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
let success = true;
|
||||
event.preventDefault();
|
||||
|
||||
success = success && await handleSettingsSave(formValues);
|
||||
await setSaveError(!success);
|
||||
if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) {
|
||||
success = await dispatch(updateModel({
|
||||
modelType: 'courseApps',
|
||||
model: {
|
||||
id: appId, enabled: formValues.enableFlexiblePeerGrade,
|
||||
},
|
||||
}));
|
||||
}
|
||||
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormValues({ enableFlexiblePeerGrade: e.target.checked });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) {
|
||||
dispatch(updateSavingStatus({ status: '' }));
|
||||
onClose();
|
||||
}
|
||||
}, [updateSettingsRequestStatus]);
|
||||
|
||||
const renderBody = () => {
|
||||
switch (loadingStatus) {
|
||||
case RequestStatus.SUCCESSFUL:
|
||||
return (
|
||||
<>
|
||||
{saveError && (
|
||||
<Alert variant="danger" icon={Info} ref={alertRef}>
|
||||
<Alert.Heading>
|
||||
{formatMessage(messages.errorSavingTitle)}
|
||||
</Alert.Heading>
|
||||
{formatMessage(messages.errorSavingMessage)}
|
||||
</Alert>
|
||||
)}
|
||||
<FormSwitchGroup
|
||||
id="enable-flexible-peer-grade"
|
||||
name="enableFlexiblePeerGrade"
|
||||
label={(
|
||||
<div className="d-flex align-items-center">
|
||||
{formatMessage(messages.enableFlexPeerGradeLabel)}
|
||||
{formValues.enableFlexiblePeerGrade && (
|
||||
<Badge className="ml-2" variant="success" data-testid="enable-badge">
|
||||
{formatMessage(messages.enabledBadgeLabel)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
helpText={(
|
||||
<div>
|
||||
<p>{formatMessage(messages.enableFlexPeerGradeHelp)}</p>
|
||||
<span className="py-3">
|
||||
<Hyperlink
|
||||
className="text-primary-500 small"
|
||||
destination={appInfo.documentationLinks?.learnMoreConfiguration}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{formatMessage(messages.ORASettingsHelpLink)}
|
||||
</Hyperlink>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
onChange={handleChange}
|
||||
checked={formValues.enableFlexiblePeerGrade}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case RequestStatus.DENIED:
|
||||
return <PermissionDeniedAlert />;
|
||||
case RequestStatus.FAILED:
|
||||
return <ConnectionErrorAlert />;
|
||||
default:
|
||||
return <Loading />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
title={formatMessage(messages.heading)}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
variant={modalVariant}
|
||||
hasCloseButton={isMobile}
|
||||
isFullscreenScroll
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
<Form onSubmit={handleSubmit} data-testid="proctoringForm">
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
{formatMessage(messages.heading)}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
{renderBody()}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer className="p-4">
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{formatMessage(messages.cancelLabel)}
|
||||
</ModalDialog.CloseButton>
|
||||
<StatefulButton
|
||||
labels={{
|
||||
default: formatMessage(messages.saveLabel),
|
||||
pending: formatMessage(messages.pendingSaveLabel),
|
||||
}}
|
||||
description="Form save button"
|
||||
data-testid="submissionButton"
|
||||
disabled={submitButtonState === RequestStatus.IN_PROGRESS}
|
||||
state={submitButtonState}
|
||||
type="submit"
|
||||
/>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</Form>
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
|
||||
ORASettings.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ORASettings;
|
||||
@@ -1,152 +0,0 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import { getCourseAppsApiUrl, getCourseAdvancedSettingsApiUrl } from 'CourseAuthoring/pages-and-resources/data/api';
|
||||
import { fetchCourseApps, fetchCourseAppSettings } from 'CourseAuthoring/pages-and-resources/data/thunks';
|
||||
import ORASettings from './Settings';
|
||||
import messages from './messages';
|
||||
import {
|
||||
courseId,
|
||||
inititalState,
|
||||
} from './factories/mockData';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const oraSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
|
||||
|
||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
||||
ReactDOM.createPortal = jest.fn(node => node);
|
||||
|
||||
const renderComponent = () => (
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<PagesAndResourcesProvider courseId={courseId}>
|
||||
<MemoryRouter initialEntries={[oraSettingsUrl]}>
|
||||
<Routes>
|
||||
<Route path={oraSettingsUrl} element={<PageWrap><ORASettings onClose={jest.fn()} /></PageWrap>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PagesAndResourcesProvider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
)
|
||||
);
|
||||
|
||||
const mockStore = async ({
|
||||
apiStatus,
|
||||
enabled,
|
||||
}) => {
|
||||
const settings = ['forceOnFlexiblePeerOpenassessments'];
|
||||
const fetchCourseAppsUrl = `${getCourseAppsApiUrl()}/${courseId}`;
|
||||
const fetchAdvancedSettingsUrl = `${getCourseAdvancedSettingsApiUrl()}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(fetchCourseAppsUrl).reply(
|
||||
200,
|
||||
[{
|
||||
allowed_operations: { enable: false, configure: true },
|
||||
description: 'setting',
|
||||
documentation_links: { learnMoreConfiguration: '' },
|
||||
enabled,
|
||||
id: 'ora_settings',
|
||||
name: 'Flexible Peer Grading for ORAs',
|
||||
}],
|
||||
);
|
||||
axiosMock.onGet(fetchAdvancedSettingsUrl).reply(
|
||||
apiStatus,
|
||||
{ force_on_flexible_peer_openassessments: { value: enabled } },
|
||||
);
|
||||
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseAppSettings(courseId, settings), store.dispatch);
|
||||
};
|
||||
|
||||
describe('ORASettings', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(inititalState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('Flexible peer grading configuration modal is visible', async () => {
|
||||
renderComponent();
|
||||
expect(screen.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays "Configure Flexible Peer Grading" heading', async () => {
|
||||
renderComponent();
|
||||
const headingElement = screen.getByText(messages.heading.defaultMessage);
|
||||
|
||||
expect(headingElement).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays loading component', () => {
|
||||
renderComponent();
|
||||
const loadingElement = screen.getByRole('status');
|
||||
|
||||
expect(within(loadingElement).getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays Connection Error Alert', async () => {
|
||||
await mockStore({ apiStatus: 404, enabled: true });
|
||||
renderComponent();
|
||||
const errorAlert = screen.getByRole('alert');
|
||||
|
||||
expect(within(errorAlert).getByText('We encountered a technical error when loading this page.', { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays Permissions Error Alert', async () => {
|
||||
await mockStore({ apiStatus: 403, enabled: true });
|
||||
renderComponent();
|
||||
const errorAlert = screen.getByRole('alert');
|
||||
|
||||
expect(within(errorAlert).getByText('You are not authorized to view this page', { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
|
||||
renderComponent();
|
||||
await mockStore({ apiStatus: 200, enabled: true });
|
||||
|
||||
waitFor(() => {
|
||||
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
||||
const enableBadge = screen.getByTestId('enable-badge');
|
||||
|
||||
expect(label).toBeVisible();
|
||||
|
||||
expect(enableBadge).toHaveTextContent('Enabled');
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays title, helper text and hides badge when flexible peer grading button is disabled', async () => {
|
||||
renderComponent();
|
||||
await mockStore({ apiStatus: 200, enabled: false });
|
||||
|
||||
const label = await screen.findByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
||||
const enableBadge = screen.queryByTestId('enable-badge');
|
||||
|
||||
expect(label).toBeVisible();
|
||||
|
||||
expect(enableBadge).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
export const courseId = 'course-v1:org+num+run';
|
||||
|
||||
export const inititalState = {
|
||||
courseDetail: {
|
||||
courseId,
|
||||
status: 'successful',
|
||||
},
|
||||
pagesAndResources: {
|
||||
courseAppIds: ['ora_settings'],
|
||||
loadingStatus: 'in-progress',
|
||||
savingStatus: '',
|
||||
courseAppsApiStatus: {},
|
||||
courseAppSettings: {},
|
||||
},
|
||||
models: {
|
||||
courseApps: {
|
||||
ora_settings: {
|
||||
id: 'ora_settings',
|
||||
name: 'Flexible Peer Grading',
|
||||
enabled: true,
|
||||
description: 'Enable flexible peer grading',
|
||||
allowedOperations: {
|
||||
enable: false,
|
||||
configure: true,
|
||||
},
|
||||
documentationLinks: {
|
||||
learnMoreConfiguration: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.pages-resources.ora.heading',
|
||||
defaultMessage: 'Configure Flexible Peer Grading',
|
||||
description: 'Title for the modal dialog header',
|
||||
},
|
||||
ORASettingsHelpLink: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.link',
|
||||
defaultMessage: 'Learn more about open response assessment settings',
|
||||
description: 'Descriptive text for the hyperlink to the docs site',
|
||||
},
|
||||
enableFlexPeerGradeLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.label',
|
||||
defaultMessage: 'Flex Peer Grading',
|
||||
description: 'Label for form switch',
|
||||
},
|
||||
enableFlexPeerGradeHelp: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.help',
|
||||
defaultMessage: 'Turn on Flexible Peer Grading for all open response assessments in the course with peer grading.',
|
||||
description: 'Help text describing what happens when the switch is enabled',
|
||||
},
|
||||
enabledBadgeLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.enabled-badge.label',
|
||||
defaultMessage: 'Enabled',
|
||||
description: 'Label for badge that show users that a setting is enabled',
|
||||
},
|
||||
cancelLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.cancel-button.label',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Label for button that cancels user changes',
|
||||
},
|
||||
saveLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-button.label',
|
||||
defaultMessage: 'Save',
|
||||
description: 'Label for button that saves user changes',
|
||||
},
|
||||
pendingSaveLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.pending-save-button.label',
|
||||
defaultMessage: 'Saving',
|
||||
description: 'Label for button that has pending api save calls',
|
||||
},
|
||||
errorSavingTitle: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.title',
|
||||
defaultMessage: 'We couldn\'t apply your changes.',
|
||||
},
|
||||
errorSavingMessage: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.message',
|
||||
defaultMessage: 'Please check your entries and try again.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-ora_settings",
|
||||
"version": "0.1.0",
|
||||
"description": "Open Response Assessment configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"react-redux": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-proctoring",
|
||||
"version": "0.1.0",
|
||||
"description": "Proctoring configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"classnames": "*",
|
||||
"email-validator": "*",
|
||||
"react": "*",
|
||||
"prop-types": "*",
|
||||
"moment": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-progress",
|
||||
"version": "0.1.0",
|
||||
"description": "Progress configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { useFormikContext } from 'formik';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import GroupEditor from './GroupEditor';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('formik', () => ({
|
||||
...jest.requireActual('formik'),
|
||||
useFormikContext: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('GroupEditor', () => {
|
||||
const mockIntl = { formatMessage: jest.fn() };
|
||||
|
||||
const mockGroup = {
|
||||
id: '1',
|
||||
name: 'Test Group',
|
||||
description: 'Test Group Description',
|
||||
type: 'open',
|
||||
maxTeamSize: 5,
|
||||
};
|
||||
|
||||
const mockProps = {
|
||||
intl: mockIntl,
|
||||
fieldNameCommonBase: 'test',
|
||||
group: mockGroup,
|
||||
onDelete: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
onBlur: jest.fn(),
|
||||
errors: {},
|
||||
};
|
||||
|
||||
const renderComponent = (overrideProps = {}) => render(
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<GroupEditor {...mockProps} {...overrideProps} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
useFormikContext.mockReturnValue({
|
||||
touched: {},
|
||||
errors: {},
|
||||
handleChange: jest.fn(),
|
||||
handleBlur: jest.fn(),
|
||||
setFieldError: jest.fn(),
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders without errors', () => {
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test('renders the group name and description', () => {
|
||||
const { getByText } = renderComponent();
|
||||
expect(getByText('Test Group')).toBeInTheDocument();
|
||||
expect(getByText('Test Group Description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('group types messages', () => {
|
||||
test('group type open message', () => {
|
||||
const { getByLabelText, getByText } = renderComponent();
|
||||
const expandButton = getByLabelText('Expand group editor');
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
fireEvent.click(expandButton);
|
||||
expect(getByText(messages.groupTypeOpenDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('group type public_managed message', () => {
|
||||
const publicManagedGroupMock = {
|
||||
id: '2',
|
||||
name: 'Test Group',
|
||||
description: 'Test Group Description',
|
||||
type: 'public_managed',
|
||||
maxTeamSize: 5,
|
||||
};
|
||||
const { getByLabelText, getByText } = renderComponent({ group: publicManagedGroupMock });
|
||||
const expandButton = getByLabelText('Expand group editor');
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
fireEvent.click(expandButton);
|
||||
expect(getByText(messages.groupTypePublicManagedDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('group type private_managed message', () => {
|
||||
const privateManagedGroupMock = {
|
||||
id: '3',
|
||||
name: 'Test Group',
|
||||
description: 'Test Group Description',
|
||||
type: 'private_managed',
|
||||
maxTeamSize: 5,
|
||||
};
|
||||
const { getByLabelText, getByText } = renderComponent({ group: privateManagedGroupMock });
|
||||
const expandButton = getByLabelText('Expand group editor');
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
fireEvent.click(expandButton);
|
||||
expect(getByText(messages.groupTypePrivateManagedDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-teams",
|
||||
"version": "0.1.0",
|
||||
"description": "Teams configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"formik": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"uuid": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
||||
|
||||
/**
|
||||
* Check if a group type is enabled by the current configuration.
|
||||
* This is a temporary workaround to disable the OPEN MANAGED team type until it is fully adopted.
|
||||
* For more information, see: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3885760525/Open+Managed+Group+Type
|
||||
* @param {string} groupType - the group type to check
|
||||
* @returns {boolean} - true if the group type is enabled
|
||||
*/
|
||||
export const isGroupTypeEnabled = (groupType) => {
|
||||
const enabledTypesByDefault = [
|
||||
GroupTypes.OPEN,
|
||||
GroupTypes.PUBLIC_MANAGED,
|
||||
GroupTypes.PRIVATE_MANAGED,
|
||||
];
|
||||
const enabledTypesByConfig = {
|
||||
[GroupTypes.OPEN_MANAGED]: getConfig().ENABLE_OPEN_MANAGED_TEAM_TYPE,
|
||||
};
|
||||
return enabledTypesByDefault.includes(groupType) || enabledTypesByConfig[groupType] || false;
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
||||
import { isGroupTypeEnabled } from './utils';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() }));
|
||||
|
||||
describe('teams utils', () => {
|
||||
describe('isGroupTypeEnabled', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('returns true if the group type is enabled', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false });
|
||||
expect(isGroupTypeEnabled(GroupTypes.OPEN)).toBe(true);
|
||||
expect(isGroupTypeEnabled(GroupTypes.PUBLIC_MANAGED)).toBe(true);
|
||||
expect(isGroupTypeEnabled(GroupTypes.PRIVATE_MANAGED)).toBe(true);
|
||||
});
|
||||
test('returns false if the OPEN_MANAGED group is not enabled', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false });
|
||||
expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true if the OPEN_MANAGED group is enabled', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
|
||||
expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false if the group is invalid', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
|
||||
expect(isGroupTypeEnabled('FOO')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false if the group is null', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
|
||||
expect(isGroupTypeEnabled(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-wiki",
|
||||
"version": "0.1.0",
|
||||
"description": "Wiki configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
Xpert Unit Summaries Configuration Plugin
|
||||
=========================================
|
||||
|
||||
Install this using ``npm install plugins/course-apps/xpert_unit_summary/ --no-save``.
|
||||
@@ -1,45 +0,0 @@
|
||||
import React, { useCallback, useContext, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import SettingsModal from './settings-modal/SettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
import { fetchXpertSettings } from './data/thunks';
|
||||
|
||||
const XpertUnitSummarySettings = ({ intl }) => {
|
||||
const { path: pagesAndResourcesPath, courseId } = useContext(PagesAndResourcesContext);
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchXpertSettings(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
navigate(pagesAndResourcesPath);
|
||||
}, [pagesAndResourcesPath]);
|
||||
|
||||
return (
|
||||
<SettingsModal
|
||||
appId="xpert-unit-summary"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableXpertUnitSummaryHelp)}
|
||||
helpPrivacyText={intl.formatMessage(messages.enableXpertUnitSummaryHelpPrivacyLink)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableXpertUnitSummaryLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableXpertUnitSummaryLink)}
|
||||
allUnitsEnabledText={intl.formatMessage(messages.allUnitsEnabledByDefault)}
|
||||
noUnitsEnabledText={intl.formatMessage(messages.noUnitsEnabledByDefault)}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
XpertUnitSummarySettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(XpertUnitSummarySettings);
|
||||
@@ -1,282 +0,0 @@
|
||||
import ReactDOM from 'react-dom';
|
||||
import React from 'react';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import {
|
||||
getConfig, initializeMockApp, setConfig,
|
||||
} from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||
import {
|
||||
findByTestId, queryByTestId, render, waitFor, getByText, fireEvent,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
|
||||
import XpertUnitSummarySettings from './Settings';
|
||||
import * as API from './data/api';
|
||||
import * as Thunks from './data/thunks';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
let axiosMock;
|
||||
let store;
|
||||
let container;
|
||||
|
||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
||||
ReactDOM.createPortal = jest.fn(node => node);
|
||||
|
||||
function renderComponent() {
|
||||
const wrapper = render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<PagesAndResourcesProvider courseId={courseId}>
|
||||
<MemoryRouter initialEntries={['/xpert-unit-summary/settings']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/xpert-unit-summary/settings"
|
||||
element={<PageWrap><XpertUnitSummarySettings courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={<PageWrap><div /></PageWrap>}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PagesAndResourcesProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
function generateCourseLevelAPIResponse({
|
||||
success, enabled,
|
||||
}) {
|
||||
return {
|
||||
response: {
|
||||
success, enabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('XpertUnitSummarySettings', () => {
|
||||
beforeEach(() => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
BASE_URL: 'http://test.edx.org',
|
||||
LMS_BASE_URL: 'http://lmstest.edx.org',
|
||||
CMS_BASE_URL: 'http://cmstest.edx.org',
|
||||
LOGIN_URL: 'http://support.edx.org/login',
|
||||
LOGOUT_URL: 'http://support.edx.org/logout',
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://support.edx.org/access_token',
|
||||
ACCESS_TOKEN_COOKIE_NAME: 'cookie',
|
||||
CSRF_TOKEN_API_PATH: '/',
|
||||
SUPPORT_URL: 'http://support.edx.org',
|
||||
});
|
||||
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore({
|
||||
models: {
|
||||
courseDetails: {
|
||||
[courseId]: {
|
||||
start: Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
describe('with successful network connections', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
test('Shows switch on if disabled from backend', async () => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: false,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).toBeTruthy();
|
||||
expect(queryByTestId(container, 'enable-badge')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Shows enable radio selected if enabled from backend', async () => {
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
expect(queryByTestId(container, 'enable-radio').checked).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Shows disable radio selected if enabled from backend', async () => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: false,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
expect(queryByTestId(container, 'disable-radio').checked).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('first time course configuration', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(400, generateCourseLevelAPIResponse({
|
||||
success: false,
|
||||
enabled: undefined,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test('Does not show as enabled if configuration does not exist', async () => {
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).not.toBeTruthy();
|
||||
expect(queryByTestId(container, 'enable-badge')).not.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saving configuration changes', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: false,
|
||||
}));
|
||||
|
||||
axiosMock.onPost(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test('Saving configuration changes', async () => {
|
||||
jest.spyOn(API, 'postXpertSettings');
|
||||
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
expect(queryByTestId(container, 'disable-radio').checked).toBeTruthy();
|
||||
fireEvent.click(queryByTestId(container, 'enable-radio'));
|
||||
fireEvent.click(getByText(container, 'Save'));
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
expect(API.postXpertSettings).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('testing configurable gating', () => {
|
||||
beforeEach(async () => {
|
||||
axiosMock.onGet(API.getXpertConfigurationStatusUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: true,
|
||||
}));
|
||||
jest.spyOn(API, 'getXpertPluginConfigurable');
|
||||
await executeThunk(Thunks.fetchXpertPluginConfigurable(courseId), store.dispatch);
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test('getting Xpert Plugin configurable status', () => {
|
||||
expect(API.getXpertPluginConfigurable).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removing course configuration', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
axiosMock.onDelete(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: undefined,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test('Deleting course configuration', async () => {
|
||||
jest.spyOn(API, 'deleteXpertSettings');
|
||||
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
fireEvent.click(container.querySelector('#enable-xpert-unit-summary-toggle'));
|
||||
fireEvent.click(getByText(container, 'Save'));
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
expect(API.deleteXpertSettings).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetting course units', () => {
|
||||
test('reset all units to be enabled', async () => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
axiosMock.onPost(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
|
||||
jest.spyOn(API, 'postXpertSettings');
|
||||
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
fireEvent.click(queryByTestId(container, 'reset-units'));
|
||||
expect(API.postXpertSettings).toBeCalledWith(courseId, { reset: true, enabled: true });
|
||||
});
|
||||
|
||||
test('reset all units to be disabled', async () => {
|
||||
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: false,
|
||||
}));
|
||||
|
||||
axiosMock.onPost(API.getXpertSettingsUrl(courseId))
|
||||
.reply(200, generateCourseLevelAPIResponse({
|
||||
success: true,
|
||||
enabled: false,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
|
||||
jest.spyOn(API, 'postXpertSettings');
|
||||
|
||||
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
|
||||
fireEvent.click(queryByTestId(container, 'reset-units'));
|
||||
expect(API.postXpertSettings).toBeCalledWith(courseId, { reset: true, enabled: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
export default {
|
||||
id: 'xpert-unit-summary',
|
||||
enabled: false,
|
||||
name: 'Xpert unit summaries',
|
||||
description: 'Use generative AI to summarize course content and reinforce learning.',
|
||||
allowedOperations: {
|
||||
enable: true,
|
||||
configure: true,
|
||||
},
|
||||
documentationLinks: {
|
||||
learnMoreConfiguration: 'https://openai.com/',
|
||||
},
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
export function getXpertSettingsUrl(courseId) {
|
||||
return `${getConfig().STUDIO_BASE_URL}/ai_aside/v1/${courseId}`;
|
||||
}
|
||||
|
||||
export function getXpertConfigurationStatusUrl(courseId) {
|
||||
return `${getConfig().STUDIO_BASE_URL}/ai_aside/v1/${courseId}/configurable`;
|
||||
}
|
||||
|
||||
export async function getXpertSettings(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getXpertSettingsUrl(courseId));
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function postXpertSettings(courseId, state) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXpertSettingsUrl(courseId), {
|
||||
enabled: state.enabled,
|
||||
reset: state.reset,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getXpertPluginConfigurable(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getXpertConfigurationStatusUrl(courseId));
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteXpertSettings(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.delete(getXpertSettingsUrl(courseId));
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { updateSavingStatus, updateLoadingStatus, updateResetStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import { addModel, updateModel } from 'CourseAuthoring/generic/model-store';
|
||||
|
||||
import {
|
||||
getXpertSettings, postXpertSettings, getXpertPluginConfigurable, deleteXpertSettings,
|
||||
} from './api';
|
||||
|
||||
export function updateXpertSettings(courseId, state) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
try {
|
||||
const { response } = await postXpertSettings(courseId, state);
|
||||
const { success } = response;
|
||||
if (success) {
|
||||
dispatch(updateModel({ modelType: 'XpertSettings', model: { id: 'xpert-unit-summary', enabled: state.enabled } }));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
}
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchXpertPluginConfigurable(courseId) {
|
||||
return async (dispatch) => {
|
||||
let enabled;
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.PENDING }));
|
||||
try {
|
||||
const { response } = await getXpertPluginConfigurable(courseId);
|
||||
enabled = response?.enabled;
|
||||
} catch (e) {
|
||||
enabled = undefined;
|
||||
}
|
||||
|
||||
dispatch(addModel({
|
||||
modelType: 'XpertSettings.enabled',
|
||||
model: {
|
||||
id: 'xpert-unit-summary',
|
||||
enabled,
|
||||
},
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchXpertSettings(courseId) {
|
||||
return async (dispatch) => {
|
||||
let enabled;
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.PENDING }));
|
||||
|
||||
try {
|
||||
const { response } = await getXpertSettings(courseId);
|
||||
enabled = response?.enabled;
|
||||
} catch (e) {
|
||||
enabled = undefined;
|
||||
}
|
||||
|
||||
dispatch(addModel({
|
||||
modelType: 'XpertSettings',
|
||||
model: {
|
||||
id: 'xpert-unit-summary',
|
||||
enabled,
|
||||
},
|
||||
}));
|
||||
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
};
|
||||
}
|
||||
|
||||
export function removeXpertSettings(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
|
||||
try {
|
||||
const { response } = await deleteXpertSettings(courseId);
|
||||
const { success } = response;
|
||||
if (success) {
|
||||
const model = { id: 'xpert-unit-summary', enabled: undefined };
|
||||
dispatch(updateModel({ modelType: 'XpertSettings', model }));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
}
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resetXpertSettings(courseId, state) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateResetStatus({ status: RequestStatus.PENDING }));
|
||||
try {
|
||||
const { response } = await postXpertSettings(courseId, state);
|
||||
const { success } = response;
|
||||
if (success) {
|
||||
dispatch(updateResetStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
}
|
||||
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.pages-resources.xpert-unit-summary.heading',
|
||||
defaultMessage: 'Configure Xpert unit summaries',
|
||||
},
|
||||
enableXpertUnitSummaryLabel: {
|
||||
id: 'course-authoring.pages-resources.xpert-unit-summary.enable-xpert-unit-summary.label',
|
||||
defaultMessage: 'Xpert unit summaries',
|
||||
},
|
||||
enableXpertUnitSummaryHelp: {
|
||||
id: 'course-authoring.pages-resources.xpert-unit-summary.enable-xpert-unit-summary.help',
|
||||
defaultMessage: 'Reinforce learning concepts by sharing text-based course content with OpenAI (via API) to display unit summaries on-demand for learners. Learners can leave feedback about the quality of the AI-generated summaries for use by edX to improve the performance of the tool.',
|
||||
},
|
||||
enableXpertUnitSummaryHelpPrivacyLink: {
|
||||
id: 'course-authoring.pages-resources.xpert-unit-summary.enable-xpert-unit-summary.help.privacylink',
|
||||
defaultMessage: 'Learn more about OpenAI API data privacy.',
|
||||
},
|
||||
enableXpertUnitSummaryLink: {
|
||||
id: 'course-authoring.pages-resources.xpert-unit-summary.enable-xpert-unit-summary.link',
|
||||
defaultMessage: 'Learn more about how OpenAI handles data',
|
||||
},
|
||||
allUnitsEnabledByDefault: {
|
||||
id: 'course-authoring.pages-resources.xpert-unit-summary.all-units-enabled-by-default',
|
||||
defaultMessage: 'All units enabled by default',
|
||||
},
|
||||
noUnitsEnabledByDefault: {
|
||||
id: 'course-authoring.pages-resources.xpert-unit-summary.no-units-enabled-by-default',
|
||||
defaultMessage: 'No units enabled by default',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-xpert_unit_summary",
|
||||
"version": "0.1.0",
|
||||
"description": "Xpert Unit Summaries configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"formik": "*",
|
||||
"prop-types": "*",
|
||||
"yup": "*",
|
||||
"react": "*",
|
||||
"react-redux": "*",
|
||||
"react-router-dom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
const ResetIcon = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
transform="scale(-1,1)"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default ResetIcon;
|
||||
@@ -1,453 +0,0 @@
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
Alert,
|
||||
Badge,
|
||||
Form,
|
||||
Icon,
|
||||
ModalDialog,
|
||||
OverlayTrigger,
|
||||
StatefulButton,
|
||||
Tooltip,
|
||||
TransitionReplace,
|
||||
Hyperlink,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
Info, CheckCircleOutline, SpinnerSimple,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { Formik } from 'formik';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {
|
||||
useContext, useEffect, useRef, useState,
|
||||
} from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
|
||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||
import Loading from 'CourseAuthoring/generic/Loading';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
||||
import { useIsMobile } from 'CourseAuthoring/utils';
|
||||
import { getLoadingStatus, getSavingStatus, getResetStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
|
||||
import { updateSavingStatus, updateResetStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
|
||||
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
|
||||
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
|
||||
import { updateXpertSettings, resetXpertSettings, removeXpertSettings } from '../data/thunks';
|
||||
import messages from './messages';
|
||||
import appInfo from '../appInfo';
|
||||
import ResetIcon from './ResetIcon';
|
||||
|
||||
import './SettingsModal.scss';
|
||||
|
||||
const AppSettingsForm = ({
|
||||
formikProps, children, showForm,
|
||||
}) => children && (
|
||||
<TransitionReplace>
|
||||
{showForm ? (
|
||||
<React.Fragment key="app-enabled">
|
||||
{children(formikProps)}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment key="app-disabled" />
|
||||
)}
|
||||
</TransitionReplace>
|
||||
);
|
||||
|
||||
AppSettingsForm.propTypes = {
|
||||
// Ignore the warning here since we're just passing along the props as-is and the child component should validate
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
formikProps: PropTypes.object.isRequired,
|
||||
showForm: PropTypes.bool.isRequired,
|
||||
children: PropTypes.func,
|
||||
};
|
||||
|
||||
AppSettingsForm.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
const SettingsModalBase = ({
|
||||
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,
|
||||
isMobile: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
footer: PropTypes.node,
|
||||
};
|
||||
|
||||
SettingsModalBase.defaultProps = {
|
||||
footer: null,
|
||||
};
|
||||
|
||||
const ResetUnitsButton = ({
|
||||
intl,
|
||||
courseId,
|
||||
checked,
|
||||
visible,
|
||||
}) => {
|
||||
const resetStatusRequestStatus = useSelector(getResetStatus);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (resetStatusRequestStatus === RequestStatus.SUCCESSFUL) {
|
||||
setTimeout(() => {
|
||||
dispatch(updateResetStatus({ status: '' }));
|
||||
}, 2000);
|
||||
}
|
||||
}, [resetStatusRequestStatus]);
|
||||
|
||||
const handleResetUnits = () => {
|
||||
dispatch(resetXpertSettings(courseId, { enabled: checked === 'true', reset: true }));
|
||||
};
|
||||
|
||||
const getResetButtonState = () => {
|
||||
switch (resetStatusRequestStatus) {
|
||||
case RequestStatus.PENDING:
|
||||
return 'pending';
|
||||
case RequestStatus.SUCCESSFUL:
|
||||
return 'finish';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible) { return null; }
|
||||
|
||||
const messageKey = checked === 'true' ? 'resetAllUnitsTooltipChecked' : 'resetAllUnitsTooltipUnchecked';
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
placement="right"
|
||||
overlay={(
|
||||
<Tooltip
|
||||
id={`tooltip-reset-${checked}`}
|
||||
className="reset-tooltip"
|
||||
>
|
||||
{intl.formatMessage(messages[messageKey])}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<StatefulButton
|
||||
className="reset-units-button"
|
||||
labels={{
|
||||
default: intl.formatMessage(messages.resetAllUnits),
|
||||
pending: '',
|
||||
finish: intl.formatMessage(messages.reset),
|
||||
}}
|
||||
icons={{
|
||||
default: <Icon src={ResetIcon} />,
|
||||
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
|
||||
finish: <Icon src={CheckCircleOutline} />,
|
||||
}}
|
||||
state={getResetButtonState()}
|
||||
onClick={handleResetUnits}
|
||||
disabledStates={['pending', 'finish']}
|
||||
variant="outline"
|
||||
data-testid="reset-units"
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
ResetUnitsButton.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
checked: PropTypes.oneOf(['true', 'false']).isRequired,
|
||||
visible: PropTypes.bool,
|
||||
};
|
||||
|
||||
ResetUnitsButton.defaultProps = {
|
||||
visible: false,
|
||||
};
|
||||
|
||||
const SettingsModal = ({
|
||||
intl,
|
||||
appId,
|
||||
title,
|
||||
children,
|
||||
configureBeforeEnable,
|
||||
initialValues,
|
||||
validationSchema,
|
||||
onClose,
|
||||
onSettingsSave,
|
||||
enableAppLabel,
|
||||
enableAppHelp,
|
||||
learnMoreText,
|
||||
helpPrivacyText,
|
||||
enableReinitialize,
|
||||
allUnitsEnabledText,
|
||||
noUnitsEnabledText,
|
||||
}) => {
|
||||
const { courseId } = useContext(PagesAndResourcesContext);
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
const updateSettingsRequestStatus = useSelector(getSavingStatus);
|
||||
const alertRef = useRef(null);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default';
|
||||
const isMobile = useIsMobile();
|
||||
const modalVariant = isMobile ? 'dark' : 'default';
|
||||
|
||||
const xpertSettings = useModel('XpertSettings', appId);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) {
|
||||
dispatch(updateSavingStatus({ status: '' }));
|
||||
onClose();
|
||||
}
|
||||
}, [updateSettingsRequestStatus]);
|
||||
|
||||
const handleFormSubmit = async ({ enabled, checked, ...rest }) => {
|
||||
let success;
|
||||
const values = { ...rest, enabled: enabled ? checked === 'true' : undefined };
|
||||
|
||||
if (enabled) {
|
||||
success = await dispatch(updateXpertSettings(courseId, values));
|
||||
} else {
|
||||
success = await dispatch(removeXpertSettings(courseId));
|
||||
}
|
||||
|
||||
if (onSettingsSave) {
|
||||
success = success && await onSettingsSave(values);
|
||||
}
|
||||
setSaveError(!success);
|
||||
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
|
||||
};
|
||||
|
||||
const handleFormikSubmit = ({ handleSubmit, errors }) => async (event) => {
|
||||
// If submitting the form with errors, show the alert and scroll to it.
|
||||
await handleSubmit(event);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setSaveError(true);
|
||||
alertRef?.current.scrollIntoView?.(); // eslint-disable-line no-unused-expressions
|
||||
}
|
||||
};
|
||||
|
||||
const learnMoreLink = appInfo.documentationLinks?.learnMoreConfiguration && (
|
||||
<div className="py-1">
|
||||
<Hyperlink
|
||||
className="text-primary-500"
|
||||
destination={appInfo.documentationLinks.learnMoreConfiguration}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{learnMoreText}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
);
|
||||
|
||||
const helpPrivacyLink = (
|
||||
<div className="py-1">
|
||||
<Hyperlink
|
||||
className="text-primary-500"
|
||||
destination="https://openai.com/api-data-privacy"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{helpPrivacyText}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loadingStatus === RequestStatus.SUCCESSFUL) {
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: xpertSettings?.enabled !== undefined,
|
||||
checked: xpertSettings?.enabled?.toString() || 'true',
|
||||
...initialValues,
|
||||
}}
|
||||
validationSchema={
|
||||
Yup.object()
|
||||
.shape({
|
||||
enabled: Yup.boolean(),
|
||||
checked: Yup.string().oneOf(['true', 'false']),
|
||||
...validationSchema,
|
||||
})
|
||||
}
|
||||
onSubmit={handleFormSubmit}
|
||||
enableReinitialize={enableReinitialize}
|
||||
>
|
||||
{(formikProps) => (
|
||||
<Form onSubmit={handleFormikSubmit(formikProps)}>
|
||||
<SettingsModalBase
|
||||
title={title}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
variant={modalVariant}
|
||||
isMobile={isMobile}
|
||||
isFullscreenOnMobile
|
||||
intl={intl}
|
||||
footer={(
|
||||
<StatefulButton
|
||||
labels={{
|
||||
default: intl.formatMessage(messages.save),
|
||||
pending: intl.formatMessage(messages.saving),
|
||||
complete: intl.formatMessage(messages.saved),
|
||||
}}
|
||||
state={submitButtonState}
|
||||
onClick={handleFormikSubmit(formikProps)}
|
||||
disabled={!formikProps.dirty}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{saveError && (
|
||||
<Alert variant="danger" icon={Info} ref={alertRef}>
|
||||
<Alert.Heading>
|
||||
{formikProps.errors.enabled?.title || intl.formatMessage(messages.errorSavingTitle)}
|
||||
</Alert.Heading>
|
||||
{formikProps.errors.enabled?.message || intl.formatMessage(messages.errorSavingMessage)}
|
||||
</Alert>
|
||||
)}
|
||||
<FormSwitchGroup
|
||||
id={`enable-${appId}-toggle`}
|
||||
name="enabled"
|
||||
onChange={formikProps.handleChange}
|
||||
onBlur={formikProps.handleBlur}
|
||||
checked={formikProps.values.enabled}
|
||||
label={(
|
||||
<div className="d-flex align-items-center">
|
||||
{enableAppLabel}
|
||||
{formikProps.values.enabled && (
|
||||
<Badge className="ml-2" variant="success" data-testid="enable-badge">
|
||||
{intl.formatMessage(messages.enabled)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
helpText={(
|
||||
<div>
|
||||
<p>{enableAppHelp}</p>
|
||||
{helpPrivacyLink}
|
||||
{learnMoreLink}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{(formikProps.values.enabled || configureBeforeEnable) && (
|
||||
<Form.RadioSet
|
||||
name="checked"
|
||||
onChange={formikProps.handleChange}
|
||||
onBlur={formikProps.handleBlur}
|
||||
value={formikProps.values.checked}
|
||||
>
|
||||
<Form.Radio
|
||||
className="summary-radio m-2 px-3"
|
||||
data-testid="enable-radio"
|
||||
value="true"
|
||||
>
|
||||
{allUnitsEnabledText}
|
||||
<ResetUnitsButton
|
||||
intl={intl}
|
||||
courseId={courseId}
|
||||
checked={formikProps.values.checked}
|
||||
visible={formikProps.values.checked === 'true'}
|
||||
/>
|
||||
</Form.Radio>
|
||||
<Form.Radio
|
||||
className="summary-radio m-2 px-3"
|
||||
data-testid="disable-radio"
|
||||
value="false"
|
||||
>
|
||||
{noUnitsEnabledText}
|
||||
<ResetUnitsButton
|
||||
intl={intl}
|
||||
courseId={courseId}
|
||||
checked={formikProps.values.checked}
|
||||
visible={formikProps.values.checked === 'false'}
|
||||
/>
|
||||
</Form.Radio>
|
||||
</Form.RadioSet>
|
||||
)}
|
||||
{(formikProps.values.enabled || configureBeforeEnable) && children
|
||||
&& <AppConfigFormDivider marginAdj={{ default: 0, sm: 0 }} />}
|
||||
<AppSettingsForm formikProps={formikProps} showForm={formikProps.values.enabled || configureBeforeEnable}>
|
||||
{children}
|
||||
</AppSettingsForm>
|
||||
</SettingsModalBase>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SettingsModalBase
|
||||
intl={intl}
|
||||
title={title}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
variant={modalVariant}
|
||||
isMobile={isMobile}
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
{loadingStatus === RequestStatus.IN_PROGRESS && <Loading />}
|
||||
{loadingStatus === RequestStatus.FAILED && <ConnectionErrorAlert />}
|
||||
{loadingStatus === RequestStatus.DENIED && <PermissionDeniedAlert />}
|
||||
</SettingsModalBase>
|
||||
);
|
||||
};
|
||||
|
||||
SettingsModal.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
appId: PropTypes.string.isRequired,
|
||||
children: PropTypes.func,
|
||||
onSettingsSave: PropTypes.func,
|
||||
initialValues: PropTypes.shape({}),
|
||||
validationSchema: PropTypes.shape({}),
|
||||
onClose: PropTypes.func.isRequired,
|
||||
enableAppLabel: PropTypes.string.isRequired,
|
||||
enableAppHelp: PropTypes.string.isRequired,
|
||||
learnMoreText: PropTypes.string.isRequired,
|
||||
helpPrivacyText: PropTypes.string.isRequired,
|
||||
allUnitsEnabledText: PropTypes.string.isRequired,
|
||||
noUnitsEnabledText: PropTypes.string.isRequired,
|
||||
configureBeforeEnable: PropTypes.bool,
|
||||
enableReinitialize: PropTypes.bool,
|
||||
};
|
||||
|
||||
SettingsModal.defaultProps = {
|
||||
children: null,
|
||||
onSettingsSave: null,
|
||||
initialValues: {},
|
||||
validationSchema: {},
|
||||
configureBeforeEnable: false,
|
||||
enableReinitialize: false,
|
||||
};
|
||||
|
||||
export default injectIntl(SettingsModal);
|
||||
@@ -1,45 +0,0 @@
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@openedx/paragon/scss/core/utilities-only";
|
||||
|
||||
.summary-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border-width: $border-width;
|
||||
border-color: $border-color;
|
||||
border-radius: $border-radius;
|
||||
border-style: solid;
|
||||
|
||||
&:has(input:checked) {
|
||||
border-width: 3px;
|
||||
border-color: theme-color("primary");
|
||||
}
|
||||
|
||||
> div {
|
||||
flex: 1;
|
||||
|
||||
> label {
|
||||
min-height: 80px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reset-units-button {
|
||||
color: $link-color;
|
||||
border-width: $border-width;
|
||||
border-color: $border-color;
|
||||
border-radius: $border-radius;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.reset-tooltip {
|
||||
.arrow::before {
|
||||
border-right-color: #00262B;
|
||||
}
|
||||
|
||||
.tooltip-inner {
|
||||
background-color: #00262B;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
cancel: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
save: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.button.save',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
saving: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.button.saving',
|
||||
defaultMessage: 'Saving',
|
||||
},
|
||||
saved: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.button.saved',
|
||||
defaultMessage: 'Saved',
|
||||
},
|
||||
retry: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.button.retry',
|
||||
defaultMessage: 'Retry',
|
||||
},
|
||||
enabled: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.badge.enabled',
|
||||
defaultMessage: 'Enabled',
|
||||
},
|
||||
disabled: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.badge.disabled',
|
||||
defaultMessage: 'Disabled',
|
||||
},
|
||||
resetAllUnits: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.reset-all-units',
|
||||
defaultMessage: 'Reset all units',
|
||||
},
|
||||
resetAllUnitsTooltipChecked: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.reset-all-units-tooltip.checked',
|
||||
defaultMessage: 'Immediately reset any unit-level changes and checked "Enable summaries" on all units.',
|
||||
},
|
||||
resetAllUnitsTooltipUnchecked: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.reset-all-units-tooltip.unchecked',
|
||||
defaultMessage: 'Immediately reset any unit-level changes and unchecked "Enable summaries" on all units.',
|
||||
},
|
||||
reset: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.reset',
|
||||
defaultMessage: 'Reset',
|
||||
},
|
||||
errorSavingTitle: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.save-error.title',
|
||||
defaultMessage: 'We couldn\'t apply your changes.',
|
||||
},
|
||||
errorSavingMessage: {
|
||||
id: 'course-authoring.pages-resources.app-settings-modal.save-error.message',
|
||||
defaultMessage: 'Please check your entries and try again.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -4,7 +4,7 @@
|
||||
<title>Course Authoring | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
|
||||
<link rel="shortcut icon" href="<%= process.env.FAVICON_URL %>" type="image/x-icon" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base",
|
||||
"schedule:weekly",
|
||||
"schedule:daily",
|
||||
":rebaseStalePrs",
|
||||
":semanticCommits",
|
||||
":dependencyDashboard"
|
||||
":semanticCommits"
|
||||
],
|
||||
"timezone": "America/New_York",
|
||||
"patch": {
|
||||
"automerge": false
|
||||
"automerge": true
|
||||
},
|
||||
"rebaseStalePrs": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"extends": [
|
||||
"schedule:daily"
|
||||
],
|
||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||
"matchPackagePatterns": ["@edx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": false
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,72 +1,86 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
|
||||
import Header from './header';
|
||||
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
|
||||
import Header from './studio-header/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 { getCourseAppsApiStatus, getLoadingStatus } 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 AppFooter = () => (
|
||||
<div className="mt-6">
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseDetail(courseId));
|
||||
dispatch(fetchWaffleFlags(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchOnlyStudioHomeData());
|
||||
}, []);
|
||||
|
||||
const courseDetail = useModel('courseDetails', courseId);
|
||||
|
||||
const courseNumber = courseDetail ? courseDetail.number : null;
|
||||
const courseOrg = courseDetail ? courseDetail.org : null;
|
||||
const courseTitle = courseDetail ? courseDetail.name : courseId;
|
||||
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
|
||||
const courseDetailStatus = useSelector(state => state.courseDetail.status);
|
||||
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS;
|
||||
const inProgress = useSelector(getLoadingStatus) === RequestStatus.IN_PROGRESS;
|
||||
const { pathname } = useLocation();
|
||||
const isEditor = pathname.includes('/editor');
|
||||
|
||||
if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) {
|
||||
return (
|
||||
<NotFoundAlert />
|
||||
);
|
||||
}
|
||||
if (courseAppsApiStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* While V2 Editors are temporarily served from their own pages
|
||||
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
|
||||
{/* While V2 Editors are tempoarily served from thier 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}
|
||||
{inProgress ? !pathname.includes('/editor/') && <Loading />
|
||||
: (
|
||||
<AppHeader
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
)}
|
||||
{children}
|
||||
{!inProgress && !isEditor && <StudioFooterSlot />}
|
||||
{!inProgress && <AppFooter />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import React from 'react';
|
||||
|
||||
import { queryByTestId, render } from '@testing-library/react';
|
||||
|
||||
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import initializeStore from './store';
|
||||
import CourseAuthoringPage from './CourseAuthoringPage';
|
||||
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
||||
import { executeThunk } from './utils';
|
||||
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
||||
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
|
||||
import { getApiWaffleFlagsUrl } from './data/api';
|
||||
import { initializeMocks, render } from './testUtils';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
let mockPathname = '/evilguy/';
|
||||
@@ -18,15 +23,50 @@ jest.mock('react-router-dom', () => ({
|
||||
}));
|
||||
let axiosMock;
|
||||
let store;
|
||||
let container;
|
||||
function renderComponent() {
|
||||
const wrapper = render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</CourseAuthoringPage>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const mocks = initializeMocks();
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
axiosMock
|
||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
||||
.reply(200, {});
|
||||
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
|
||||
const mockStore = async () => {
|
||||
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const courseAppsApiUrl = `${apiBaseUrl}/api/course_apps/v1/apps`;
|
||||
axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(403, {
|
||||
response: { status: 403 },
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
};
|
||||
describe('DiscussionsSettings', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
test('renders permission error in case of 403', async () => {
|
||||
await mockStore();
|
||||
renderComponent();
|
||||
expect(queryByTestId(container, 'permissionDeniedAlert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor Pages Load no header', () => {
|
||||
@@ -38,13 +78,29 @@ describe('Editor Pages Load no header', () => {
|
||||
});
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
};
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
test('renders no loading wheel on editor pages', async () => {
|
||||
mockPathname = '/editor/';
|
||||
await mockStoreSuccess();
|
||||
const wrapper = render(
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</CourseAuthoringPage>
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</CourseAuthoringPage>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
expect(wrapper.queryByRole('status')).not.toBeInTheDocument();
|
||||
@@ -53,53 +109,15 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course authoring page', () => {
|
||||
const lmsApiBaseUrl = getConfig().LMS_BASE_URL;
|
||||
const courseDetailApiUrl = `${lmsApiBaseUrl}/api/courses/v1/courses`;
|
||||
const mockStoreNotFound = async () => {
|
||||
axiosMock.onGet(
|
||||
`${courseDetailApiUrl}/${courseId}?username=abc123`,
|
||||
).reply(404, {
|
||||
response: { status: 404 },
|
||||
});
|
||||
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
|
||||
};
|
||||
const mockStoreError = async () => {
|
||||
axiosMock.onGet(
|
||||
`${courseDetailApiUrl}/${courseId}?username=abc123`,
|
||||
).reply(500, {
|
||||
response: { status: 500 },
|
||||
});
|
||||
await executeThunk(fetchCourseDetail(courseId), store.dispatch);
|
||||
};
|
||||
test('renders not found page on non-existent course key', async () => {
|
||||
await mockStoreNotFound();
|
||||
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
|
||||
expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument();
|
||||
});
|
||||
test('does not render not found page on other kinds of error', async () => {
|
||||
await mockStoreError();
|
||||
// Currently, loading errors are not handled, so we wait for the child
|
||||
// content to be rendered -which happens when request status is no longer
|
||||
// IN_PROGRESS but also not NOT_FOUND or DENIED- then check that the not
|
||||
// found alert is not present.
|
||||
const contentTestId = 'courseAuthoringPageContent';
|
||||
const wrapper = render(
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<div data-testid={contentTestId} />
|
||||
</CourseAuthoringPage>
|
||||
,
|
||||
);
|
||||
expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument();
|
||||
expect(wrapper.queryByTestId('notFoundAlert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Navigate, Routes, Route, useParams,
|
||||
} from 'react-router-dom';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { PageWrap } from '@edx/frontend-platform/react';
|
||||
import { Textbooks } from 'CourseAuthoring/textbooks';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Switch, useRouteMatch } from 'react-router';
|
||||
import { PageRoute } from '@edx/frontend-platform/react';
|
||||
import CourseAuthoringPage from './CourseAuthoringPage';
|
||||
import { PagesAndResources } from './pages-and-resources';
|
||||
import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings';
|
||||
import EditorContainer from './editors/EditorContainer';
|
||||
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
|
||||
import CustomPages from './custom-pages';
|
||||
import { FilesPage, VideosPage } from './files-and-videos';
|
||||
import { AdvancedSettings } from './advanced-settings';
|
||||
import { CourseOutline } from './course-outline';
|
||||
import ScheduleAndDetails from './schedule-and-details';
|
||||
import { GradingSettings } from './grading-settings';
|
||||
import CourseTeam from './course-team/CourseTeam';
|
||||
import { CourseUpdates } from './course-updates';
|
||||
import { CourseUnit } from './course-unit';
|
||||
import { Certificates } from './certificates';
|
||||
import CourseExportPage from './export-page/CourseExportPage';
|
||||
import CourseOptimizerPage from './optimizer-page/CourseOptimizerPage';
|
||||
import CourseImportPage from './import-page/CourseImportPage';
|
||||
import { DECODED_ROUTES } from './constants';
|
||||
import CourseChecklist from './course-checklist';
|
||||
import GroupConfigurations from './group-configurations';
|
||||
import { CourseLibraries } from './course-libraries';
|
||||
import { IframeProvider } from './generic/hooks/context/iFrameContext';
|
||||
|
||||
/**
|
||||
* As of this writing, these routes are mounted at a path prefixed with the following:
|
||||
@@ -44,106 +23,32 @@ import { IframeProvider } from './generic/hooks/context/iFrameContext';
|
||||
* can move the Header/Footer rendering to this component and likely pull the course detail loading
|
||||
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
|
||||
*/
|
||||
const CourseAuthoringRoutes = () => {
|
||||
const { courseId } = useParams();
|
||||
|
||||
const CourseAuthoringRoutes = ({ courseId }) => {
|
||||
const { path } = useRouteMatch();
|
||||
return (
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={<PageWrap><CourseOutline courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
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>}
|
||||
/>
|
||||
<Route
|
||||
path="videos"
|
||||
element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? <PageWrap><VideosPage courseId={courseId} /></PageWrap> : null}
|
||||
/>
|
||||
<Route
|
||||
path="pages-and-resources/*"
|
||||
element={<PageWrap><PagesAndResources courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="proctored-exam-settings"
|
||||
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
|
||||
/>
|
||||
<Route
|
||||
path="custom-pages/*"
|
||||
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
element={<PageWrap><IframeProvider><CourseUnit courseId={courseId} /></IframeProvider></PageWrap>}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
path="editor/course-videos/:blockId"
|
||||
element={<PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="editor/:blockType/:blockId?"
|
||||
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="settings/details"
|
||||
element={<PageWrap><ScheduleAndDetails courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="settings/grading"
|
||||
element={<PageWrap><GradingSettings courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
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>}
|
||||
/>
|
||||
<Route
|
||||
path="import"
|
||||
element={<PageWrap><CourseImportPage courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
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>
|
||||
<Switch>
|
||||
<PageRoute path={`${path}/pages-and-resources`}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/proctored-exam-settings`}>
|
||||
<ProctoredExamSettings courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/editor/:blockType/:blockId`}>
|
||||
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
|
||||
&& (
|
||||
<EditorContainer
|
||||
courseId={courseId}
|
||||
/>
|
||||
)}
|
||||
</PageRoute>
|
||||
</Switch>
|
||||
</CourseAuthoringPage>
|
||||
);
|
||||
};
|
||||
|
||||
CourseAuthoringRoutes.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CourseAuthoringRoutes;
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
|
||||
import { executeThunk } from './utils';
|
||||
import { getApiWaffleFlagsUrl } from './data/api';
|
||||
import { fetchWaffleFlags } from './data/thunks';
|
||||
import {
|
||||
screen, initializeMocks, render, waitFor,
|
||||
} from './testUtils';
|
||||
|
||||
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', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
courseId,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the TinyMceWidget
|
||||
jest.mock('./editors/sharedComponents/TinyMceWidget', () => ({
|
||||
__esModule: true, // Required to mock a default export
|
||||
default: () => <div>Widget</div>,
|
||||
Footer: () => <div>Footer</div>,
|
||||
prepareEditorRef: jest.fn(() => ({
|
||||
refReady: true,
|
||||
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('./pages-and-resources/PagesAndResources', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return pagesAndResourcesMockText;
|
||||
});
|
||||
jest.mock('./editors/EditorContainer', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return editorContainerMockText;
|
||||
});
|
||||
jest.mock('./selectors/VideoSelectorContainer', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return videoSelectorContainerMockText;
|
||||
});
|
||||
jest.mock('./custom-pages/CustomPages', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return customPagesMockText;
|
||||
});
|
||||
|
||||
describe('<CourseAuthoringRoutes>', () => {
|
||||
beforeEach(async () => {
|
||||
const { axiosMock, reduxStore } = initializeMocks();
|
||||
store = reduxStore;
|
||||
axiosMock
|
||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
||||
.reply(200, {});
|
||||
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
|
||||
});
|
||||
|
||||
it('renders the PagesAndResources component when the pages and resources route is active', async () => {
|
||||
render(
|
||||
<CourseAuthoringRoutes />,
|
||||
{ routerProps: { initialEntries: ['/pages-and-resources'] } },
|
||||
);
|
||||
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 () => {
|
||||
render(
|
||||
<CourseAuthoringRoutes />,
|
||||
{ routerProps: { initialEntries: ['/editor/video/block-id'] } },
|
||||
);
|
||||
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 () => {
|
||||
render(
|
||||
<CourseAuthoringRoutes />,
|
||||
{ routerProps: { initialEntries: ['/editor/course-videos/block-id'] } },
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
export default {
|
||||
content: {
|
||||
id: 67,
|
||||
userId: 3,
|
||||
created: '2024-01-16T13:09:11.540615Z',
|
||||
purpose: 'clipboard',
|
||||
status: 'ready',
|
||||
blockType: 'sequential',
|
||||
blockTypeDisplay: 'Subsection',
|
||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
|
||||
displayName: 'Sequences',
|
||||
},
|
||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
|
||||
sourceContextTitle: 'Demonstration Course',
|
||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
export default {
|
||||
content: {
|
||||
id: 67,
|
||||
userId: 3,
|
||||
created: '2024-01-16T13:09:11.540615Z',
|
||||
purpose: 'clipboard',
|
||||
status: 'ready',
|
||||
blockType: 'vertical',
|
||||
blockTypeDisplay: 'Unit',
|
||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
|
||||
displayName: 'Introduction: Video and Sequences',
|
||||
},
|
||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
||||
sourceContextTitle: 'Demonstration Course',
|
||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
export default {
|
||||
content: {
|
||||
id: 69,
|
||||
userId: 3,
|
||||
created: '2024-01-16T13:33:21.314439Z',
|
||||
purpose: 'clipboard',
|
||||
status: 'ready',
|
||||
blockType: 'html',
|
||||
blockTypeDisplay: 'Text',
|
||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/69/olx',
|
||||
displayName: 'Blank HTML Page',
|
||||
},
|
||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@html+block@html1',
|
||||
sourceContextTitle: 'Demonstration Course',
|
||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as clipboardUnit } from './clipboardUnit';
|
||||
export { default as clipboardSubsection } from './clipboardSubsection';
|
||||
export { default as clipboardXBlock } from './clipboardXBlock';
|
||||
@@ -1,98 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, MailtoLink, Stack } from '@openedx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const AccessibilityBody = ({
|
||||
communityAccessibilityLink,
|
||||
email,
|
||||
}) => (
|
||||
<div className="mt-5">
|
||||
<header>
|
||||
<h2 className="mb-4 pb-1">
|
||||
<FormattedMessage {...messages.a11yBodyPageHeader} />
|
||||
</h2>
|
||||
</header>
|
||||
<Stack gap={2.5}>
|
||||
<div className="small">
|
||||
<FormattedMessage
|
||||
{...messages.a11yBodyIntroGraph}
|
||||
values={{
|
||||
communityAccessibilityLink: (
|
||||
<Hyperlink
|
||||
destination={communityAccessibilityLink}
|
||||
data-testid="accessibility-page-link"
|
||||
>
|
||||
Website Accessibility Policy
|
||||
</Hyperlink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="small">
|
||||
<FormattedMessage {...messages.a11yBodyStepsHeader} />
|
||||
</div>
|
||||
<ol className="small m-0">
|
||||
<li>
|
||||
<FormattedMessage
|
||||
{...messages.a11yBodyEmailHeading}
|
||||
values={{
|
||||
emailElement: (
|
||||
<MailtoLink
|
||||
to={email}
|
||||
data-testid="email-element"
|
||||
>
|
||||
{email}
|
||||
</MailtoLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<ul>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyNameEmail} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyInstitution} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyBarrier} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyTimeConstraints} />
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyReceipt} />
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage {...messages.a11yBodyExtraInfo} />
|
||||
</li>
|
||||
</ol>
|
||||
<div className="small">
|
||||
<FormattedMessage
|
||||
{...messages.a11yBodyA11yFeedback}
|
||||
values={{
|
||||
emailElement: (
|
||||
<MailtoLink
|
||||
to={email}
|
||||
data-testid="email-element"
|
||||
>
|
||||
{email}
|
||||
</MailtoLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
|
||||
AccessibilityBody.propTypes = {
|
||||
communityAccessibilityLink: PropTypes.string.isRequired,
|
||||
email: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccessibilityBody);
|
||||
@@ -1,46 +0,0 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import initializeStore from '../../store';
|
||||
|
||||
import AccessibilityBody from './index';
|
||||
|
||||
let store;
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AccessibilityBody
|
||||
communityAccessibilityLink="http://example.com"
|
||||
email="example@example.com"
|
||||
/>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AccessibilityBody />', () => {
|
||||
describe('renders', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore({});
|
||||
});
|
||||
it('contains links', () => {
|
||||
renderComponent();
|
||||
expect(screen.getAllByTestId('email-element')).toHaveLength(2);
|
||||
expect(screen.getAllByTestId('accessibility-page-link')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
import AccessibilityBody from './AccessibilityBody';
|
||||
|
||||
export default AccessibilityBody;
|
||||
@@ -1,111 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
a11yBodyPolicyLink: {
|
||||
id: 'a11yBodyPolicyLink',
|
||||
defaultMessage: 'Website Accessibility Policy',
|
||||
description: 'Title for link to full accessibility policy.',
|
||||
},
|
||||
a11yBodyPageHeader: {
|
||||
id: 'a11yBodyPageHeader',
|
||||
defaultMessage: 'Individualized Accessibility Process for Course Creators',
|
||||
description: 'Heading for studio\'s accessibility policy page.',
|
||||
},
|
||||
a11yBodyIntroGraph: {
|
||||
id: 'a11yBodyIntroGraph',
|
||||
defaultMessage: `At edX, we seek to understand and respect the unique needs and perspectives of the edX global community.
|
||||
We value every course team and are committed to expanding access to all, including course team creators and authors with
|
||||
disabilities. To that end, we have adopted a {communityAccessibilityLink} and this process to allow course team creators
|
||||
and authors to request assistance if they are unable to develop and post content on our platform via Studio because of their
|
||||
disabilities.`,
|
||||
description: 'Introductory paragraph outlining why we care about accessibility, and what we\'re doing about it.',
|
||||
},
|
||||
a11yBodyStepsHeader: {
|
||||
id: 'a11yBodyStepsHeader',
|
||||
defaultMessage: 'Course team creators and authors needing such assistance should take the following steps:',
|
||||
description: 'Heading for list of steps authors can take for accessibility requests.',
|
||||
},
|
||||
a11yBodyEdxResponse: {
|
||||
id: 'a11yBodyEdxResponse',
|
||||
defaultMessage: `'We will communicate with you about your preferences and needs in determining the appropriate solution, although
|
||||
the ultimate decision will be ours, provided that the solution is effective and timely. The factors we will consider in choosing
|
||||
an accessibility solution are: effectiveness; timeliness (relative to your deadlines); ease of implementation; and ease of use for
|
||||
you. We will notify you of the decision and explain the basis for our decision within 10 business days of discussing with you.`,
|
||||
description: 'Paragraph outlining how we will select an accessibility solution.',
|
||||
},
|
||||
a11yBodyEdxFollowUp: {
|
||||
id: 'a11yBodyEdxFollowUp',
|
||||
defaultMessage: `Thereafter, we will communicate with you on a weekly basis regarding our evaluation, decision, and progress in
|
||||
implementing the accessibility solution. We will notify you when implementation of your accessibility solution is complete and
|
||||
will follow-up with you as may be necessary to see if the solution was effective.`,
|
||||
description: 'Paragraph outlining how we will follow-up with you during and after implementing an accessibility solution.',
|
||||
},
|
||||
a11yBodyOngoingSupport: {
|
||||
id: 'a11yBodyOngoingSupport',
|
||||
defaultMessage: 'EdX will provide ongoing technical support as needed and will address any additional issues that arise after the initial course creation.',
|
||||
description: 'A statement of ongoing support.',
|
||||
},
|
||||
a11yBodyA11yFeedback: {
|
||||
id: 'a11yBodyA11yFeedback',
|
||||
defaultMessage: 'Please direct any questions or suggestions on how to improve the accessibility of Studio to {emailElement} or use the form below. We welcome your feedback.',
|
||||
description: 'Contact information heading for those with accessibility issues or suggestions.',
|
||||
},
|
||||
a11yBodyEmailHeading: {
|
||||
id: 'a11yBodyEmailHeading',
|
||||
defaultMessage: 'Send an email to {emailElement} with the following information:',
|
||||
description: 'Heading for list of information required when you email us.',
|
||||
},
|
||||
a11yBodyNameEmail: {
|
||||
id: 'a11yBodyNameEmail',
|
||||
defaultMessage: 'your name and email address;',
|
||||
description: 'Your contact information.',
|
||||
},
|
||||
a11yBodyInstitution: {
|
||||
id: 'a11yBodyInstitution',
|
||||
defaultMessage: 'the edX member institution that you are affiliated with;',
|
||||
description: 'edX affiliate information.',
|
||||
},
|
||||
a11yBodyBarrier: {
|
||||
id: 'a11yBodyBarrier',
|
||||
defaultMessage: 'a brief description of the challenge or barrier to access that you are experiencing; and',
|
||||
description: 'Accessibility problem information.',
|
||||
},
|
||||
a11yBodyTimeConstraints: {
|
||||
id: 'a11yBodyTimeConstraints',
|
||||
defaultMessage: 'how soon you need access and for how long (e.g., a planned course start date or in connection with a course-related deadline such as a final essay).',
|
||||
description: 'Time contstraint information.',
|
||||
},
|
||||
a11yBodyReceipt: {
|
||||
id: 'a11yBodyReceipt',
|
||||
defaultMessage: 'The edX Support Team will respond to confirm receipt and forward your request to the edX Partner Manager for your institution and the edX Website Accessibility Specialist.',
|
||||
description: 'Paragraph outlining what steps edX will take immediately.',
|
||||
},
|
||||
a11yBodyExtraInfo: {
|
||||
id: 'a11yBodyExtraInfo',
|
||||
defaultMessage: `With guidance from the Website Accessibility Specialist, edX will contact you to discuss your request and gather
|
||||
additional information from you about your preferences and needs, to determine if there's a workable solution that edX is able to support.`,
|
||||
description: 'Paragraph outlining how and when edX will reach out to you.',
|
||||
},
|
||||
a11yBodyFixesListHeader: {
|
||||
id: 'a11yBodyFixesListHeader',
|
||||
defaultMessage: 'EdX will assist you promptly and thoroughly so that you are able to create content on the CMS within your time constraints. Such efforts may include, but are not limited to:',
|
||||
description: 'Heading for list of ways we might be able to assist.',
|
||||
},
|
||||
a11yBodyThirdParty: {
|
||||
id: 'a11yBodyThirdParty',
|
||||
defaultMessage: 'Purchasing a third-party tool or software for use on an individual basis to assist your use of Studio;',
|
||||
description: 'Buy third-party software.',
|
||||
},
|
||||
a11yBodyContractor: {
|
||||
id: 'a11yBodyContractor',
|
||||
defaultMessage: 'Engaging a trained independent contractor to provide real-time visual, verbal and physical assistance; or',
|
||||
description: 'Hire a contractor.',
|
||||
},
|
||||
a11yBodyCodeFix: {
|
||||
id: 'a11yBodyCodeFix',
|
||||
defaultMessage: 'Developing new code to implement a technical fix.',
|
||||
description: 'Make a technical fix.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,146 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
injectIntl, FormattedMessage, intlShape, FormattedDate, FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow, Alert, Form, Stack, StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { STATEFUL_BUTTON_STATES } from '../../constants';
|
||||
import submitAccessibilityForm from '../data/thunks';
|
||||
import useAccessibility from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
const AccessibilityForm = ({
|
||||
accessibilityEmail,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const {
|
||||
errors,
|
||||
values,
|
||||
isFormFilled,
|
||||
dispatch,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
hasErrorField,
|
||||
savingStatus,
|
||||
} = useAccessibility({ name: '', email: '', message: '' }, intl);
|
||||
|
||||
const formFields = [
|
||||
{
|
||||
label: intl.formatMessage(messages.accessibilityPolicyFormEmailLabel),
|
||||
name: 'email',
|
||||
value: values.email,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(messages.accessibilityPolicyFormNameLabel),
|
||||
name: 'name',
|
||||
value: values.name,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage(messages.accessibilityPolicyFormMessageLabel),
|
||||
name: 'message',
|
||||
value: values.message,
|
||||
},
|
||||
];
|
||||
|
||||
const createButtonState = {
|
||||
labels: {
|
||||
default: intl.formatMessage(messages.accessibilityPolicyFormSubmitLabel),
|
||||
pending: intl.formatMessage(messages.accessibilityPolicyFormSubmittingFeedbackLabel),
|
||||
},
|
||||
disabledStates: [STATEFUL_BUTTON_STATES.pending],
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
dispatch(submitAccessibilityForm(values));
|
||||
};
|
||||
|
||||
const start = new Date('Mon Jan 29 2018 13:00:00 GMT (UTC)');
|
||||
const end = new Date('Fri Feb 2 2018 21:00:00 GMT (UTC)');
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="my-4">
|
||||
<FormattedMessage {...messages.accessibilityPolicyFormHeader} />
|
||||
</h2>
|
||||
{savingStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Alert variant="success">
|
||||
<Stack gap={2}>
|
||||
<div className="mb-2">
|
||||
<FormattedMessage {...messages.accessibilityPolicyFormSuccess} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
{...messages.accessibilityPolicyFormSuccessDetails}
|
||||
values={{
|
||||
day_start: (<FormattedDate value={start} weekday="long" />),
|
||||
time_start: (<FormattedTime value={start} timeZoneName="short" />),
|
||||
day_end: (<FormattedDate value={end} weekday="long" />),
|
||||
time_end: (<FormattedTime value={end} timeZoneName="short" />),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{savingStatus === RequestStatus.FAILED && (
|
||||
<Alert variant="danger">
|
||||
<div data-testid="rate-limit-alert">
|
||||
<FormattedMessage
|
||||
{...messages.accessibilityPolicyFormErrorHighVolume}
|
||||
values={{
|
||||
emailLink: <a href={`mailto:${accessibilityEmail}`}>{accessibilityEmail}</a>,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
<Form>
|
||||
{formFields.map((field) => (
|
||||
<Form.Group size="sm" key={field.label}>
|
||||
<Form.Control
|
||||
value={field.value}
|
||||
name={field.name}
|
||||
isInvalid={hasErrorField(field.name)}
|
||||
type={field.name === 'email' ? 'email' : null}
|
||||
as={field.name === 'message' ? 'textarea' : 'input'}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
floatingLabel={field.label}
|
||||
/>
|
||||
{hasErrorField(field.name) && (
|
||||
<Form.Control.Feedback type="invalid" data-testid={`error-feedback-${field.name}`}>
|
||||
{errors[field.name]}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
))}
|
||||
</Form>
|
||||
<ActionRow>
|
||||
<StatefulButton
|
||||
key="save-button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!isFormFilled}
|
||||
state={
|
||||
savingStatus === RequestStatus.IN_PROGRESS
|
||||
? STATEFUL_BUTTON_STATES.pending
|
||||
: STATEFUL_BUTTON_STATES.default
|
||||
}
|
||||
{...createButtonState}
|
||||
/>
|
||||
</ActionRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AccessibilityForm.propTypes = {
|
||||
accessibilityEmail: PropTypes.string.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccessibilityForm);
|
||||
@@ -1,164 +0,0 @@
|
||||
import {
|
||||
render,
|
||||
act,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
import AccessibilityForm from './index';
|
||||
import { getZendeskrUrl } from '../data/api';
|
||||
import messages from './messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const defaultProps = {
|
||||
accessibilityEmail: 'accessibilityTest@test.com',
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
accessibilityPage: {
|
||||
savingStatus: '',
|
||||
},
|
||||
};
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AccessibilityForm {...defaultProps} />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AccessibilityPolicyForm />', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
describe('renders', () => {
|
||||
beforeEach(() => {
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
it('correct number of form fields', () => {
|
||||
const formSections = screen.getAllByRole('textbox');
|
||||
const formButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||
expect(formSections).toHaveLength(3);
|
||||
expect(formButton).toBeVisible();
|
||||
});
|
||||
|
||||
it('hides StatusAlert on initial load', () => {
|
||||
expect(screen.queryAllByRole('alert')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusAlert', () => {
|
||||
let formSections;
|
||||
let submitButton;
|
||||
beforeEach(async () => {
|
||||
renderComponent();
|
||||
formSections = screen.getAllByRole('textbox');
|
||||
await act(async () => {
|
||||
userEvent.type(formSections[0], 'email@email.com');
|
||||
userEvent.type(formSections[1], 'test name');
|
||||
userEvent.type(formSections[2], 'feedback message');
|
||||
});
|
||||
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||
});
|
||||
|
||||
it('shows correct success message', async () => {
|
||||
axiosMock.onPost(getZendeskrUrl()).reply(200);
|
||||
await act(async () => {
|
||||
userEvent.click(submitButton);
|
||||
});
|
||||
const { savingStatus } = store.getState().accessibilityPage;
|
||||
expect(savingStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
||||
|
||||
expect(screen.getByText(messages.accessibilityPolicyFormSuccess.defaultMessage)).toBeVisible();
|
||||
|
||||
formSections.forEach(input => {
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows correct rate limiting message', async () => {
|
||||
axiosMock.onPost(getZendeskrUrl()).reply(429);
|
||||
await act(async () => {
|
||||
userEvent.click(submitButton);
|
||||
});
|
||||
const { savingStatus } = store.getState().accessibilityPage;
|
||||
expect(savingStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
||||
|
||||
expect(screen.getByTestId('rate-limit-alert')).toBeVisible();
|
||||
|
||||
formSections.forEach(input => {
|
||||
expect(input.value).not.toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
let formSections;
|
||||
let submitButton;
|
||||
beforeEach(async () => {
|
||||
renderComponent();
|
||||
formSections = screen.getAllByRole('textbox');
|
||||
await act(async () => {
|
||||
userEvent.type(formSections[0], 'email@email.com');
|
||||
userEvent.type(formSections[1], 'test name');
|
||||
userEvent.type(formSections[2], 'feedback message');
|
||||
});
|
||||
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||
});
|
||||
|
||||
it('adds validation checking on each input field', async () => {
|
||||
await act(async () => {
|
||||
userEvent.clear(formSections[0]);
|
||||
userEvent.clear(formSections[1]);
|
||||
userEvent.clear(formSections[2]);
|
||||
});
|
||||
const emailError = screen.getByTestId('error-feedback-email');
|
||||
expect(emailError).toBeVisible();
|
||||
|
||||
const fullNameError = screen.getByTestId('error-feedback-email');
|
||||
expect(fullNameError).toBeVisible();
|
||||
|
||||
const messageError = screen.getByTestId('error-feedback-message');
|
||||
expect(messageError).toBeVisible();
|
||||
});
|
||||
|
||||
it('sumbit button is disabled when trying to submit with all empty fields', async () => {
|
||||
await act(async () => {
|
||||
userEvent.clear(formSections[0]);
|
||||
userEvent.clear(formSections[1]);
|
||||
userEvent.clear(formSections[2]);
|
||||
userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(submitButton.closest('button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import messages from './messages';
|
||||
|
||||
const useAccessibility = (initialValues, intl) => {
|
||||
const dispatch = useDispatch();
|
||||
const savingStatus = useSelector(state => state.accessibilityPage.savingStatus);
|
||||
const [isFormFilled, setFormFilled] = useState(false);
|
||||
const validationSchema = Yup.object().shape({
|
||||
name: Yup.string().required(
|
||||
intl.formatMessage(messages.accessibilityPolicyFormValidName),
|
||||
),
|
||||
email: Yup.string()
|
||||
.email(intl.formatMessage(messages.accessibilityPolicyFormValidEmail))
|
||||
.required(intl.formatMessage(messages.accessibilityPolicyFormValidEmail)),
|
||||
message: Yup.string().required(
|
||||
intl.formatMessage(messages.accessibilityPolicyFormValidMessage),
|
||||
),
|
||||
});
|
||||
|
||||
const {
|
||||
values, errors, touched, handleChange, handleBlur, handleReset,
|
||||
} = useFormik({
|
||||
initialValues,
|
||||
enableReinitialize: true,
|
||||
validateOnBlur: false,
|
||||
validationSchema,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setFormFilled(Object.values(values).every((i) => i));
|
||||
}, [values]);
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
handleReset();
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
const hasErrorField = (fieldName) => !!errors[fieldName] && !!touched[fieldName];
|
||||
|
||||
return {
|
||||
errors,
|
||||
values,
|
||||
isFormFilled,
|
||||
dispatch,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
hasErrorField,
|
||||
savingStatus,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAccessibility;
|
||||
@@ -1,3 +0,0 @@
|
||||
import AccessibilityForm from './AccessibilityForm';
|
||||
|
||||
export default AccessibilityForm;
|
||||
@@ -1,76 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
accessibilityPolicyFormEmailLabel: {
|
||||
id: 'accessibilityPolicyFormEmailLabel',
|
||||
defaultMessage: 'Email Address',
|
||||
description: 'Label for the email form field',
|
||||
},
|
||||
accessibilityPolicyFormErrorHighVolume: {
|
||||
id: 'accessibilityPolicyFormErrorHighVolume',
|
||||
defaultMessage: 'We are currently experiencing high volume. Try again later today or send an email message to {emailLink}.',
|
||||
description: 'Error message when site is experiencing high volume that will include an email link',
|
||||
},
|
||||
accessibilityPolicyFormErrorMissingFields: {
|
||||
id: 'accessibilityPolicyFormErrorMissingFields',
|
||||
defaultMessage: 'Make sure to fill in all fields.',
|
||||
description: 'Error message to instruct user to fill in all fields',
|
||||
},
|
||||
accessibilityPolicyFormHeader: {
|
||||
id: 'accessibilityPolicyFormHeader',
|
||||
defaultMessage: 'Studio Accessibility Feedback',
|
||||
description: 'The heading for the form',
|
||||
},
|
||||
accessibilityPolicyFormMessageLabel: {
|
||||
id: 'accessibilityPolicyFormMessageLabel',
|
||||
defaultMessage: 'Message',
|
||||
description: 'Label for the message form field',
|
||||
},
|
||||
accessibilityPolicyFormNameLabel: {
|
||||
id: 'accessibilityPolicyFormNameLabel',
|
||||
defaultMessage: 'Name',
|
||||
description: 'Label for the name form field',
|
||||
},
|
||||
accessibilityPolicyFormSubmitAria: {
|
||||
id: 'accessibilityPolicyFormSubmitAria',
|
||||
defaultMessage: 'Submit Accessibility Feedback Form',
|
||||
description: 'Detailed aria-label for the submit button',
|
||||
},
|
||||
accessibilityPolicyFormSubmitLabel: {
|
||||
id: 'accessibilityPolicyFormSubmitLabel',
|
||||
defaultMessage: 'Submit',
|
||||
description: 'General label for the submit button',
|
||||
},
|
||||
accessibilityPolicyFormSubmittingFeedbackLabel: {
|
||||
id: 'accessibilityPolicyFormSubmittingFeedbackLabel',
|
||||
defaultMessage: 'Submitting',
|
||||
description: 'Loading message while form feedback is being submitted',
|
||||
},
|
||||
accessibilityPolicyFormSuccess: {
|
||||
id: 'accessibilityPolicyFormSuccess',
|
||||
defaultMessage: 'Thank you for contacting edX!',
|
||||
description: 'Simple thank you message when form submission is successful',
|
||||
},
|
||||
accessibilityPolicyFormSuccessDetails: {
|
||||
id: 'accessibilityPolicyFormSuccessDetails',
|
||||
defaultMessage: 'Thank you for your feedback regarding the accessibility of Studio. We typically respond within one business day ({day_start} to {day_end}, {time_start} to {time_end}).',
|
||||
description: 'Detailed thank you message when form submission is successful',
|
||||
},
|
||||
accessibilityPolicyFormValidEmail: {
|
||||
id: 'accessibilityPolicyFormValidEmail',
|
||||
defaultMessage: 'Enter a valid email address.',
|
||||
description: 'Error message for when an invalid email is entered into the form',
|
||||
},
|
||||
accessibilityPolicyFormValidMessage: {
|
||||
id: 'accessibilityPolicyFormValidMessage',
|
||||
defaultMessage: 'Enter a message.',
|
||||
description: 'Error message an invalid message is entered into the form',
|
||||
},
|
||||
accessibilityPolicyFormValidName: {
|
||||
id: 'accessibilityPolicyFormValidName',
|
||||
defaultMessage: 'Enter a name.',
|
||||
description: 'Error message an invalid name is entered into the form',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,42 +0,0 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Container } from '@openedx/paragon';
|
||||
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
|
||||
|
||||
import Header from '../header';
|
||||
import messages from './messages';
|
||||
import AccessibilityBody from './AccessibilityBody';
|
||||
import AccessibilityForm from './AccessibilityForm';
|
||||
|
||||
const AccessibilityPage = ({
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const communityAccessibilityLink = 'https://www.edx.org/accessibility';
|
||||
const email = 'accessibility@edx.org';
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{intl.formatMessage(messages.pageTitle, {
|
||||
siteName: process.env.SITE_NAME,
|
||||
})}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Header isHiddenMainMenu />
|
||||
<Container size="xl" classNamae="px-4">
|
||||
<AccessibilityBody {...{ email, communityAccessibilityLink }} />
|
||||
<AccessibilityForm accessibilityEmail={email} />
|
||||
</Container>
|
||||
<StudioFooterSlot />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AccessibilityPage.propTypes = {
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AccessibilityPage);
|
||||
@@ -1,46 +0,0 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import initializeStore from '../store';
|
||||
import AccessibilityPage from './index';
|
||||
|
||||
const initialState = {
|
||||
accessibilityPage: {
|
||||
status: {},
|
||||
},
|
||||
};
|
||||
let store;
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AccessibilityPage />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AccessibilityPolicyPage />', () => {
|
||||
describe('renders', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
});
|
||||
it('contains the policy body', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText('Individualized Accessibility Process for Course Creators')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
ensureConfig([
|
||||
'STUDIO_BASE_URL',
|
||||
], 'Course Apps API service');
|
||||
|
||||
export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getZendeskrUrl = () => `${getApiBaseUrl()}/zendesk_proxy/v0`;
|
||||
|
||||
/**
|
||||
* Posts the form data to zendesk endpoint
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<[{}]>}
|
||||
*/
|
||||
export async function postAccessibilityForm({ name, email, message }) {
|
||||
const data = {
|
||||
name,
|
||||
tags: ['studio_a11y'],
|
||||
email: {
|
||||
from: email,
|
||||
subject: 'Studio Accessibility Request',
|
||||
message,
|
||||
},
|
||||
};
|
||||
|
||||
await getAuthenticatedHttpClient().post(getZendeskrUrl(), data);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'accessibilityPage',
|
||||
initialState: {
|
||||
savingStatus: '',
|
||||
},
|
||||
reducers: {
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { postAccessibilityForm } from './api';
|
||||
import { updateSavingStatus } from './slice';
|
||||
|
||||
function submitAccessibilityForm({ email, name, message }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await postAccessibilityForm({ email, name, message });
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 429) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
} else {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default submitAccessibilityForm;
|
||||
@@ -1,3 +0,0 @@
|
||||
import AccessibilityPage from './AccessibilityPage';
|
||||
|
||||
export default AccessibilityPage;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
pageTitle: {
|
||||
id: 'course-authoring.accessibility.page.title',
|
||||
defaultMessage: 'Studio Accessibility Policy| {siteName}',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,285 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Container, Button, Layout, StatefulButton, TransitionReplace,
|
||||
} from '@openedx/paragon';
|
||||
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import Placeholder from '../editors/Placeholder';
|
||||
|
||||
import AlertProctoringError from '../generic/AlertProctoringError';
|
||||
import { useModel } from '../generic/model-store';
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import { parseArrayOrObjectValues } from '../utils';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
import { fetchCourseAppSettings, updateCourseAppSetting, fetchProctoringExamErrors } from './data/thunks';
|
||||
import {
|
||||
getCourseAppSettings, getSavingStatus, getProctoringExamErrors, getSendRequestErrors, getLoadingStatus,
|
||||
} from './data/selectors';
|
||||
import SettingCard from './setting-card/SettingCard';
|
||||
import SettingsSidebar from './settings-sidebar/SettingsSidebar';
|
||||
import validateAdvancedSettingsData from './utils';
|
||||
import messages from './messages';
|
||||
import ModalError from './modal-error/ModalError';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
|
||||
const AdvancedSettings = ({ intl, courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
|
||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||
const [errorModal, showErrorModal] = useState(false);
|
||||
const [editedSettings, setEditedSettings] = useState({});
|
||||
const [errorFields, setErrorFields] = useState([]);
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
|
||||
const [isQueryPending, setIsQueryPending] = useState(false);
|
||||
const [isEditableState, setIsEditableState] = useState(false);
|
||||
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
|
||||
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseAppSettings(courseId));
|
||||
dispatch(fetchProctoringExamErrors(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
const advancedSettingsData = useSelector(getCourseAppSettings);
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
const proctoringExamErrors = useSelector(getProctoringExamErrors);
|
||||
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
|
||||
const loadingSettingsStatus = useSelector(getLoadingStatus);
|
||||
|
||||
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
|
||||
const updateSettingsButtonState = {
|
||||
labels: {
|
||||
default: intl.formatMessage(messages.buttonSaveText),
|
||||
pending: intl.formatMessage(messages.buttonSavingText),
|
||||
},
|
||||
disabledStates: ['pending'],
|
||||
};
|
||||
const {
|
||||
proctoringErrors,
|
||||
mfeProctoredExamSettingsUrl,
|
||||
} = proctoringExamErrors;
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
setIsQueryPending(false);
|
||||
setShowSuccessAlert(true);
|
||||
setIsEditableState(false);
|
||||
setTimeout(() => setShowSuccessAlert(false), 15000);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
showSaveSettingsPrompt(false);
|
||||
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
|
||||
setErrorFields(settingsWithSendErrors);
|
||||
showErrorModal(true);
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
}
|
||||
if (loadingSettingsStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<div className="row justify-content-center m-6">
|
||||
<Placeholder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleResetSettingsValues = () => {
|
||||
setIsEditableState(false);
|
||||
showErrorModal(false);
|
||||
setEditedSettings({});
|
||||
showSaveSettingsPrompt(false);
|
||||
};
|
||||
|
||||
const handleSettingBlur = () => {
|
||||
validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
|
||||
};
|
||||
|
||||
const handleUpdateAdvancedSettingsData = () => {
|
||||
const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
|
||||
if (isValid) {
|
||||
setIsQueryPending(true);
|
||||
} else {
|
||||
showSaveSettingsPrompt(false);
|
||||
showErrorModal(!errorModal);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInternetConnectionFailed = () => {
|
||||
setInternetConnectionError(true);
|
||||
showSaveSettingsPrompt(false);
|
||||
setShowSuccessAlert(false);
|
||||
};
|
||||
|
||||
const handleQueryProcessing = () => {
|
||||
setShowSuccessAlert(false);
|
||||
dispatch(updateCourseAppSetting(courseId, parseArrayOrObjectValues(editedSettings)));
|
||||
};
|
||||
|
||||
const handleManuallyChangeClick = (setToState) => {
|
||||
showErrorModal(setToState);
|
||||
showSaveSettingsPrompt(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container size="xl" className="advanced-settings px-4">
|
||||
<div className="setting-header mt-5">
|
||||
{(proctoringErrors?.length > 0) && (
|
||||
<AlertProctoringError
|
||||
icon={Info}
|
||||
proctoringErrorsData={proctoringErrors}
|
||||
aria-hidden="true"
|
||||
aria-labelledby={intl.formatMessage(messages.alertProctoringAriaLabelledby)}
|
||||
aria-describedby={intl.formatMessage(messages.alertProctoringDescribedby)}
|
||||
/>
|
||||
)}
|
||||
<TransitionReplace>
|
||||
{showSuccessAlert ? (
|
||||
<AlertMessage
|
||||
key={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
|
||||
show={showSuccessAlert}
|
||||
variant="success"
|
||||
icon={CheckCircle}
|
||||
title={intl.formatMessage(messages.alertSuccess)}
|
||||
description={intl.formatMessage(messages.alertSuccessDescriptions)}
|
||||
aria-hidden="true"
|
||||
aria-labelledby={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
|
||||
aria-describedby={intl.formatMessage(messages.alertSuccessAriaDescribedby)}
|
||||
/>
|
||||
) : null}
|
||||
</TransitionReplace>
|
||||
</div>
|
||||
<section className="setting-items mb-4">
|
||||
<Layout
|
||||
lg={[{ span: 9 }, { span: 3 }]}
|
||||
md={[{ span: 9 }, { span: 3 }]}
|
||||
sm={[{ span: 9 }, { span: 3 }]}
|
||||
xs={[{ span: 9 }, { span: 3 }]}
|
||||
xl={[{ span: 9 }, { span: 3 }]}
|
||||
>
|
||||
<Layout.Element>
|
||||
<SubHeader
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
title={intl.formatMessage(messages.headingTitle)}
|
||||
contentTitle={intl.formatMessage(messages.policy)}
|
||||
/>
|
||||
<article>
|
||||
<div>
|
||||
<section className="setting-items-policies">
|
||||
<div className="small">
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.policies.description"
|
||||
defaultMessage="{notice} Do not modify these policies unless you are familiar with their purpose."
|
||||
values={{ notice: <strong>Warning: </strong> }}
|
||||
/>
|
||||
</div>
|
||||
<div className="setting-items-deprecated-setting">
|
||||
<Button
|
||||
variant={showDeprecated ? 'outline-brand' : 'tertiary'}
|
||||
onClick={() => setShowDeprecated(!showDeprecated)}
|
||||
size="sm"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.deprecated.button.text"
|
||||
defaultMessage="{visibility} deprecated settings"
|
||||
values={{
|
||||
visibility:
|
||||
showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText)
|
||||
: intl.formatMessage(messages.deprecatedButtonShowText),
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="setting-items-list p-0">
|
||||
{Object.keys(advancedSettingsData).map((settingName) => {
|
||||
const settingData = advancedSettingsData[settingName];
|
||||
if (settingData.deprecated && !showDeprecated) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SettingCard
|
||||
key={settingName}
|
||||
settingData={settingData}
|
||||
name={settingName}
|
||||
showSaveSettingsPrompt={showSaveSettingsPrompt}
|
||||
saveSettingsPrompt={saveSettingsPrompt}
|
||||
setEdited={setEditedSettings}
|
||||
handleBlur={handleSettingBlur}
|
||||
isEditableState={isEditableState}
|
||||
setIsEditableState={setIsEditableState}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</Layout.Element>
|
||||
<Layout.Element>
|
||||
<SettingsSidebar
|
||||
courseId={courseId}
|
||||
proctoredExamSettingsUrl={mfeProctoredExamSettingsUrl}
|
||||
/>
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
</section>
|
||||
</Container>
|
||||
<div className="alert-toast">
|
||||
{isQueryPending && (
|
||||
<InternetConnectionAlert
|
||||
isFailed={savingStatus === RequestStatus.FAILED}
|
||||
isQueryPending={isQueryPending}
|
||||
onQueryProcessing={handleQueryProcessing}
|
||||
onInternetConnectionFailed={handleInternetConnectionFailed}
|
||||
/>
|
||||
)}
|
||||
<AlertMessage
|
||||
show={saveSettingsPrompt}
|
||||
aria-hidden={saveSettingsPrompt}
|
||||
aria-labelledby={intl.formatMessage(messages.alertWarningAriaLabelledby)}
|
||||
aria-describedby={intl.formatMessage(messages.alertWarningAriaDescribedby)}
|
||||
role="dialog"
|
||||
actions={[
|
||||
!isQueryPending && (
|
||||
<Button variant="tertiary" onClick={handleResetSettingsValues}>
|
||||
{intl.formatMessage(messages.buttonCancelText)}
|
||||
</Button>
|
||||
),
|
||||
<StatefulButton
|
||||
key="statefulBtn"
|
||||
onClick={handleUpdateAdvancedSettingsData}
|
||||
state={isQueryPending ? RequestStatus.PENDING : 'default'}
|
||||
{...updateSettingsButtonState}
|
||||
/>,
|
||||
].filter(Boolean)}
|
||||
variant="warning"
|
||||
icon={Warning}
|
||||
title={intl.formatMessage(messages.alertWarning)}
|
||||
description={intl.formatMessage(messages.alertWarningDescriptions)}
|
||||
/>
|
||||
</div>
|
||||
<ModalError
|
||||
isError={errorModal}
|
||||
showErrorModal={(setToState) => handleManuallyChangeClick(setToState)}
|
||||
handleUndoChanges={handleResetSettingsValues}
|
||||
settingsData={advancedSettingsData}
|
||||
errorList={errorFields.length > 0 ? errorFields : []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AdvancedSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AdvancedSettings);
|
||||
@@ -1,164 +0,0 @@
|
||||
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';
|
||||
import { updateCourseAppSetting } from './data/thunks';
|
||||
import AdvancedSettings from './AdvancedSettings';
|
||||
import messages from './messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
|
||||
// Mock the TextareaAutosize component
|
||||
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
<textarea
|
||||
{...props}
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
)));
|
||||
|
||||
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(() => {
|
||||
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(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
|
||||
selector: 'h2.sub-header-title',
|
||||
});
|
||||
expect(advancedSettingsElement).toBeInTheDocument();
|
||||
expect(getByText(messages.policy.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should render setting element', async () => {
|
||||
const { getByText, queryByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const advancedModuleListTitle = getByText(/Advanced Module List/i);
|
||||
expect(advancedModuleListTitle).toBeInTheDocument();
|
||||
expect(queryByText('Certificate web/html view enabled')).toBeNull();
|
||||
});
|
||||
});
|
||||
it('should change to onСhange', async () => {
|
||||
const { getByLabelText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const textarea = getByLabelText(/Advanced Module List/i);
|
||||
expect(textarea).toBeInTheDocument();
|
||||
fireEvent.change(textarea, { target: { value: '[1, 2, 3]' } });
|
||||
expect(textarea.value).toBe('[1, 2, 3]');
|
||||
});
|
||||
});
|
||||
it('should display a warning alert', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const textarea = getByLabelText(/Advanced Module List/i);
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
expect(getByText(messages.buttonCancelText.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.buttonSaveText.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.alertWarning.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.alertWarningDescriptions.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should display a tooltip on clicking on the icon', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const button = getByLabelText(/Show help text/i);
|
||||
fireEvent.click(button);
|
||||
expect(getByText(/Enter the names of the advanced modules to use in your course./i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should change deprecated button text ', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
|
||||
expect(showDeprecatedItemsBtn).toBeInTheDocument();
|
||||
fireEvent.click(showDeprecatedItemsBtn);
|
||||
expect(getByText(/Hide Deprecated Settings/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
|
||||
});
|
||||
it('should reset to default value on click on Cancel button', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
});
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
expect(textarea.value).toBe('[3, 2, 1]');
|
||||
fireEvent.click(getByText(messages.buttonCancelText.defaultMessage));
|
||||
expect(textarea.value).toBe('[]');
|
||||
});
|
||||
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
});
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } });
|
||||
expect(textarea.value).toBe('[3, 2, 1,');
|
||||
fireEvent.click(getByText('Save changes'));
|
||||
fireEvent.click(getByText('Change manually'));
|
||||
expect(textarea.value).toBe('[3, 2, 1,');
|
||||
});
|
||||
it('should show success alert after save', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
});
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
expect(textarea.value).toBe('[3, 2, 1]');
|
||||
axiosMock
|
||||
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
|
||||
.reply(200, {
|
||||
...advancedSettingsMock,
|
||||
advancedModules: {
|
||||
...advancedSettingsMock.advancedModules,
|
||||
value: [3, 2, 1],
|
||||
},
|
||||
});
|
||||
fireEvent.click(getByText('Save changes'));
|
||||
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
|
||||
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
module.exports = {
|
||||
advancedModules: {
|
||||
deprecated: false,
|
||||
displayName: 'Advanced Module List',
|
||||
help: 'Enter the names of the advanced modules to use in your course.',
|
||||
hideOnEnabledPublisher: false,
|
||||
value: [],
|
||||
},
|
||||
certHtmlViewEnabled: {
|
||||
deprecated: true,
|
||||
display_name: 'Certificate web/html view enabled',
|
||||
help: 'If true, certificate Web/HTML views are enabled for the course.',
|
||||
hide_on_enabled_publisher: false,
|
||||
value: true,
|
||||
},
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { default as advancedSettingsMock } from './advancedSettings';
|
||||
@@ -1,40 +0,0 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { convertObjectToSnakeCase } from '../../utils';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getCourseAdvancedSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings/${courseId}`;
|
||||
const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/proctoring_errors/`;
|
||||
|
||||
/**
|
||||
* Get's advanced setting for a course.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getCourseAdvancedSettings(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates advanced setting for a course.
|
||||
* @param {string} courseId
|
||||
* @param {object} settings
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function updateCourseAdvancedSettings(courseId, settings) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets proctoring exam errors.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getProctoringExamErrors(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export const getLoadingStatus = (state) => state.advancedSettings.loadingStatus;
|
||||
export const getCourseAppSettings = state => state.advancedSettings.courseAppSettings;
|
||||
export const getSavingStatus = (state) => state.advancedSettings.savingStatus;
|
||||
export const getProctoringExamErrors = (state) => state.advancedSettings.proctoringErrors;
|
||||
export const getSendRequestErrors = (state) => state.advancedSettings.sendRequestErrors.developer_message;
|
||||
@@ -1,48 +0,0 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'advancedSettings',
|
||||
initialState: {
|
||||
loadingStatus: RequestStatus.IN_PROGRESS,
|
||||
savingStatus: '',
|
||||
courseAppSettings: {},
|
||||
proctoringErrors: {},
|
||||
sendRequestErrors: {},
|
||||
},
|
||||
reducers: {
|
||||
updateLoadingStatus: (state, { payload }) => {
|
||||
state.loadingStatus = payload.status;
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
fetchCourseAppsSettingsSuccess: (state, { payload }) => {
|
||||
Object.assign(state.courseAppSettings, payload);
|
||||
},
|
||||
updateCourseAppsSettingsSuccess: (state, { payload }) => {
|
||||
Object.assign(state.courseAppSettings, payload);
|
||||
},
|
||||
getDataSendErrors: (state, { payload }) => {
|
||||
Object.assign(state.sendRequestErrors, payload);
|
||||
},
|
||||
fetchProctoringExamErrorsSuccess: (state, { payload }) => {
|
||||
Object.assign(state.proctoringErrors, payload);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
getDataSendErrors,
|
||||
fetchCourseAppsSettingsSuccess,
|
||||
updateCourseAppsSettingsSuccess,
|
||||
fetchProctoringExamErrorsSuccess,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
@@ -1,85 +0,0 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import {
|
||||
getCourseAdvancedSettings,
|
||||
updateCourseAdvancedSettings,
|
||||
getProctoringExamErrors,
|
||||
} from './api';
|
||||
import {
|
||||
fetchCourseAppsSettingsSuccess,
|
||||
updateCourseAppsSettingsSuccess,
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
fetchProctoringExamErrorsSuccess,
|
||||
getDataSendErrors,
|
||||
} from './slice';
|
||||
|
||||
export function fetchCourseAppSettings(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const settingValues = await getCourseAdvancedSettings(courseId);
|
||||
const sortedDisplayName = [];
|
||||
Object.values(settingValues).forEach(value => {
|
||||
const { displayName } = value;
|
||||
sortedDisplayName.push(displayName);
|
||||
});
|
||||
const sortedSettingValues = {};
|
||||
sortedDisplayName.sort().forEach((displayName => {
|
||||
Object.entries(settingValues).forEach(([key, value]) => {
|
||||
if (value.displayName === displayName) {
|
||||
sortedSettingValues[key] = value;
|
||||
}
|
||||
});
|
||||
}));
|
||||
dispatch(fetchCourseAppsSettingsSuccess(sortedSettingValues));
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 403) {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED }));
|
||||
} else {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCourseAppSetting(courseId, settings) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const settingValues = await updateCourseAdvancedSettings(courseId, settings);
|
||||
dispatch(updateCourseAppsSettingsSuccess(settingValues));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
let errorData;
|
||||
try {
|
||||
const { customAttributes: { httpErrorResponseData } } = error;
|
||||
errorData = JSON.parse(httpErrorResponseData);
|
||||
} catch (err) {
|
||||
errorData = {};
|
||||
}
|
||||
|
||||
dispatch(getDataSendErrors(errorData));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchProctoringExamErrors(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const settingValues = await getProctoringExamErrors(courseId);
|
||||
dispatch(fetchProctoringExamErrorsSuccess(settingValues));
|
||||
return true;
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default as AdvancedSettings } from './AdvancedSettings';
|
||||
@@ -1,86 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
headingTitle: {
|
||||
id: 'course-authoring.advanced-settings.heading.title',
|
||||
defaultMessage: 'Advanced settings',
|
||||
},
|
||||
headingSubtitle: {
|
||||
id: 'course-authoring.advanced-settings.heading.subtitle',
|
||||
defaultMessage: 'Settings',
|
||||
},
|
||||
policy: {
|
||||
id: 'course-authoring.advanced-settings.policies.title',
|
||||
defaultMessage: 'Manual policy definition',
|
||||
},
|
||||
alertWarning: {
|
||||
id: 'course-authoring.advanced-settings.alert.warning',
|
||||
defaultMessage: "You've made some changes",
|
||||
},
|
||||
alertWarningDescriptions: {
|
||||
id: 'course-authoring.advanced-settings.alert.warning.descriptions',
|
||||
defaultMessage: 'Your changes will not take effect until you save your progress. Take care with key and value formatting, as validation is not implemented.',
|
||||
},
|
||||
alertSuccess: {
|
||||
id: 'course-authoring.advanced-settings.alert.success',
|
||||
defaultMessage: 'Your policy changes have been saved.',
|
||||
},
|
||||
alertSuccessDescriptions: {
|
||||
id: 'course-authoring.advanced-settings.alert.success.descriptions',
|
||||
defaultMessage: 'No validation is performed on policy keys or value pairs. If you are having difficulties, check your formatting.',
|
||||
},
|
||||
alertProctoringError: {
|
||||
id: 'course-authoring.advanced-settings.alert.proctoring.error',
|
||||
defaultMessage: 'This course has protected exam setting that are incomplete or invalid.',
|
||||
},
|
||||
alertProctoringErrorDescriptions: {
|
||||
id: 'course-authoring.advanced-settings.alert.proctoring.error.descriptions',
|
||||
defaultMessage: 'You will be unable to make changes until the following setting are updated on the page below.',
|
||||
},
|
||||
buttonSaveText: {
|
||||
id: 'course-authoring.advanced-settings.alert.button.save',
|
||||
defaultMessage: 'Save changes',
|
||||
},
|
||||
buttonSavingText: {
|
||||
id: 'course-authoring.advanced-settings.alert.button.saving',
|
||||
defaultMessage: 'Saving',
|
||||
},
|
||||
buttonCancelText: {
|
||||
id: 'course-authoring.advanced-settings.alert.button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
deprecatedButtonShowText: {
|
||||
id: 'course-authoring.advanced-settings.deprecated.button.show',
|
||||
defaultMessage: 'Show',
|
||||
},
|
||||
deprecatedButtonHideText: {
|
||||
id: 'course-authoring.advanced-settings.deprecated.button.hide',
|
||||
defaultMessage: 'Hide',
|
||||
},
|
||||
alertWarningAriaLabelledby: {
|
||||
id: 'course-authoring.advanced-settings.alert.warning.aria.labelledby',
|
||||
defaultMessage: 'notification-warning-title',
|
||||
},
|
||||
alertWarningAriaDescribedby: {
|
||||
id: 'course-authoring.advanced-settings.alert.warning.aria.describedby',
|
||||
defaultMessage: 'notification-warning-description',
|
||||
},
|
||||
alertSuccessAriaLabelledby: {
|
||||
id: 'course-authoring.advanced-settings.alert.success.aria.labelledby',
|
||||
defaultMessage: 'alert-confirmation-title',
|
||||
},
|
||||
alertSuccessAriaDescribedby: {
|
||||
id: 'course-authoring.advanced-settings.alert.success.aria.describedby',
|
||||
defaultMessage: 'alert-confirmation-description',
|
||||
},
|
||||
alertProctoringAriaLabelledby: {
|
||||
id: 'course-authoring.advanced-settings.alert.proctoring.error.aria.labelledby',
|
||||
defaultMessage: 'alert-danger-title',
|
||||
},
|
||||
alertProctoringDescribedby: {
|
||||
id: 'course-authoring.advanced-settings.alert.proctoring.error.aria.describedby',
|
||||
defaultMessage: 'alert-danger-description',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,63 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ActionRow, AlertModal, Button } from '@openedx/paragon';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ModalErrorListItem from './ModalErrorListItem';
|
||||
import messages from './messages';
|
||||
|
||||
const ModalError = ({
|
||||
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.
|
||||
Please check the following validation feedbacks and reflect them in your course settings:"
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</AlertModal>
|
||||
);
|
||||
|
||||
ModalError.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
isError: PropTypes.bool.isRequired,
|
||||
handleUndoChanges: PropTypes.func.isRequired,
|
||||
showErrorModal: PropTypes.func.isRequired,
|
||||
errorList: PropTypes.arrayOf(PropTypes.shape({
|
||||
key: PropTypes.string,
|
||||
message: PropTypes.string,
|
||||
})).isRequired,
|
||||
settingsData: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ModalError);
|
||||