Compare commits
1 Commits
release/ul
...
refactor--
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c044186dba |
25
.env
@@ -1,4 +1,3 @@
|
||||
APP_ID='authoring'
|
||||
NODE_ENV='production'
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
BASE_URL=''
|
||||
@@ -24,28 +23,24 @@ 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
|
||||
ENABLE_NEW_HOME_PAGE = false
|
||||
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
|
||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
|
||||
ENABLE_NEW_GRADING_PAGE = false
|
||||
ENABLE_NEW_COURSE_TEAM_PAGE = false
|
||||
ENABLE_NEW_IMPORT_PAGE = false
|
||||
ENABLE_NEW_EXPORT_PAGE = false
|
||||
ENABLE_UNIT_PAGE = false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=false
|
||||
INVITE_STUDENTS_EMAIL_TO=''
|
||||
ENABLE_CHECKLIST_QUALITY=''
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
COURSE_TEAM_SUPPORT_EMAIL=''
|
||||
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
APP_ID='authoring'
|
||||
NODE_ENV='development'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2001'
|
||||
@@ -26,29 +25,24 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='Your Plaform Name Here'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
STUDIO_SHORT_NAME='Studio'
|
||||
SUPPORT_EMAIL=
|
||||
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
|
||||
ENABLE_NEW_HOME_PAGE = false
|
||||
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
|
||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
|
||||
ENABLE_NEW_GRADING_PAGE = false
|
||||
ENABLE_NEW_COURSE_TEAM_PAGE = false
|
||||
ENABLE_NEW_IMPORT_PAGE = false
|
||||
ENABLE_NEW_EXPORT_PAGE = false
|
||||
ENABLE_UNIT_PAGE = false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=true
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
COURSE_TEAM_SUPPORT_EMAIL=''
|
||||
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'
|
||||
|
||||
23
.env.test
@@ -1,4 +1,3 @@
|
||||
APP_ID='authoring'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2001'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
@@ -23,22 +22,20 @@ 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
|
||||
ENABLE_NEW_HOME_PAGE = false
|
||||
ENABLE_NEW_COURSE_OUTLINE_PAGE = true
|
||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE = true
|
||||
ENABLE_NEW_GRADING_PAGE = true
|
||||
ENABLE_NEW_COURSE_TEAM_PAGE = true
|
||||
ENABLE_NEW_IMPORT_PAGE = true
|
||||
ENABLE_NEW_EXPORT_PAGE = true
|
||||
ENABLE_UNIT_PAGE = true
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
|
||||
PARAGON_THEME_URLS=
|
||||
COURSE_TEAM_SUPPORT_EMAIL='support@example.com'
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
coverage/*
|
||||
dist/
|
||||
node_modules/
|
||||
jest.config.js
|
||||
env.config.jsx
|
||||
example.env.config.jsx
|
||||
jest.config.js
|
||||
22
.eslintrc.js
@@ -1,6 +1,5 @@
|
||||
const path = require('path');
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig(
|
||||
'eslint',
|
||||
@@ -11,25 +10,8 @@ module.exports = createConfig(
|
||||
}],
|
||||
'template-curly-spacing': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
indent: ['error', 2],
|
||||
'no-restricted-exports': 'off',
|
||||
// There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers
|
||||
'no-restricted-syntax': 'off',
|
||||
},
|
||||
settings: {
|
||||
// Import URLs should be resolved using aliases
|
||||
'import/resolver': {
|
||||
webpack: {
|
||||
config: path.resolve(__dirname, 'webpack.dev.config.js'),
|
||||
},
|
||||
},
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['plugins/**/*.test.jsx'],
|
||||
rules: {
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
7
.github/dependabot.yml
vendored
@@ -1,7 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Adding new check for github-actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
38
.github/pull_request_template.md
vendored
@@ -1,38 +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
|
||||
[OEP-19](https://open-edx-proposals.readthedocs.io/en/latest/oep-0019-bp-developer-documentation.html), and can be linked here.
|
||||
|
||||
Useful information to include:
|
||||
- Which user roles will this change impact? Common user roles are "Learner", "Course Author",
|
||||
"Developer", and "Operator".
|
||||
- Include screenshots for changes to the UI (ideally, both "before" and "after" screenshots, if applicable).
|
||||
|
||||
## Supporting information
|
||||
|
||||
Link to other information about the change, such as GitHub issues, or Discourse discussions.
|
||||
Be sure to check they are publicly readable, or if not, repeat the information here.
|
||||
|
||||
## Testing instructions
|
||||
|
||||
Please provide detailed step-by-step instructions for manually testing this change.
|
||||
|
||||
## Other information
|
||||
|
||||
Include anything else that will help reviewers and consumers understand the change.
|
||||
- Does this change depend on other changes elsewhere?
|
||||
- Any special concerns or limitations? For example: deprecations, migrations, security, or accessibility.
|
||||
|
||||
## Best Practices Checklist
|
||||
|
||||
We're trying to move away from some deprecated patterns in this codebase. Please
|
||||
check if your PR meets these recommendations before asking for a review:
|
||||
|
||||
- [ ] Any _new_ files are using TypeScript (`.ts`, `.tsx`).
|
||||
- [ ] Deprecated `propTypes`, `defaultProps`, and `injectIntl` patterns are not used in any new or modified code.
|
||||
- [ ] Tests should use the helpers in `src/testUtils.tsx` (specifically `initializeMocks`)
|
||||
- [ ] Do not add new fields to the Redux state/store. Use React Context to share state among multiple components.
|
||||
- [ ] Use React Query to load data from REST APIs. See any `apiHooks.ts` in this repo for examples.
|
||||
- [ ] All new i18n messages in `messages.ts` files have a `description` for translators to use.
|
||||
- [ ] Imports avoid using `../`. To import from parent folders, use `@src`, e.g. `import { initializeMocks } from '@src/testUtils';` instead of `from '../../../../testUtils'`
|
||||
18
.github/workflows/add-issue-to-btr-project.yml
vendored
@@ -1,18 +0,0 @@
|
||||
# Run the workflow that adds new tickets that are labelled "release testing"
|
||||
# to the org-wide BTR project board
|
||||
|
||||
name: Add release testing issues to the BTR project board
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
# This workflow is triggered when an issue is labeled with 'release testing'.
|
||||
# It adds the issue to the BTR project and applies the 'needs triage' label
|
||||
# if it doesn't already have it.
|
||||
|
||||
jobs:
|
||||
handle-release-testing:
|
||||
uses: openedx/.github/.github/workflows/add-issue-to-btr-project.yml@master
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
15
.github/workflows/add-to-cc-board.yml
vendored
@@ -1,15 +0,0 @@
|
||||
name: Trigger to add Issue or PR to a Core Contributor project board
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
add-to-cc-board:
|
||||
if: github.event.label.name == 'Core Contributor assignee'
|
||||
uses: openedx/.github/.github/workflows/add-to-cc-board.yml@master
|
||||
with:
|
||||
board_name: cc-frontend-apps
|
||||
secrets:
|
||||
projects_access_token: ${{ secrets.PROJECTS_TOKEN }}
|
||||
28
.github/workflows/validate.yml
vendored
@@ -9,31 +9,15 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
- run: make validate.ci
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: code-coverage-report
|
||||
path: coverage/*.*
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
pattern: code-coverage-report
|
||||
path: coverage
|
||||
merge-multiple: true
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"ignoreUnits": ["\\.5"]
|
||||
}],
|
||||
"property-no-vendor-prefix": [true, {
|
||||
"ignoreProperties": ["animation", "filter", "transform", "transition"]
|
||||
"ignoreProperties": ["animation", "filter"]
|
||||
}],
|
||||
"value-no-vendor-prefix": [true, {
|
||||
"ignoreValues": ["fill-available"]
|
||||
@@ -26,7 +26,6 @@
|
||||
"scss/at-rule-no-unknown": true,
|
||||
"scss/at-import-partial-extension": null,
|
||||
"scss/comment-no-empty": null,
|
||||
"import-notation": "string",
|
||||
"property-no-unknown": [true, {
|
||||
"ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"]
|
||||
}],
|
||||
|
||||
9
.tx/config
Normal file
@@ -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
|
||||
|
||||
35
Makefile
@@ -1,17 +1,21 @@
|
||||
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,it_IT,pt_PT,de_DE"
|
||||
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
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 +33,34 @@ detect_changed_source_translations:
|
||||
# Checking for changed translations...
|
||||
git diff --exit-code $(i18n)
|
||||
|
||||
# Pushes translations to Transifex. You must run make extract_translations first.
|
||||
push_translations:
|
||||
# Pushing strings to Transifex...
|
||||
tx push -s
|
||||
# Fetching hashes from Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
|
||||
# Writing out comments to file...
|
||||
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
ifeq ($(OPENEDX_ATLAS_PULL),)
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
|
||||
else
|
||||
# Pulls translations using atlas.
|
||||
pull_translations:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull $(ATLAS_OPTIONS) \
|
||||
translations/frontend-component-ai-translations/src/i18n/messages:frontend-component-ai-translations \
|
||||
translations/frontend-platform/src/i18n/messages:frontend-platform \
|
||||
&& atlas pull --filter=$(transifex_langs) \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring
|
||||
|
||||
$(intl_imports) frontend-component-ai-translations frontend-platform paragon frontend-component-footer frontend-app-course-authoring
|
||||
$(intl_imports) paragon frontend-component-footer frontend-app-course-authoring
|
||||
endif
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
@@ -52,8 +72,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
|
||||
|
||||
267
README.rst
@@ -1,5 +1,5 @@
|
||||
frontend-app-authoring
|
||||
######################
|
||||
frontend-app-course-authoring
|
||||
#############################
|
||||
|
||||
|license-badge| |status-badge| |codecov-badge|
|
||||
|
||||
@@ -7,100 +7,60 @@ frontend-app-authoring
|
||||
Purpose
|
||||
*******
|
||||
|
||||
This implements most of the frontend for **Open edX Studio**, allowing authors to create and edit courses, libraries, and their learning components.
|
||||
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
|
||||
|
||||
A few parts of Studio still default to the `"legacy" pages defined in edx-platform <https://github.com/openedx/edx-platform/tree/master/cms>`_, but those are rapidly being deprecated and replaced with the React- and Paragon-based pages defined here.
|
||||
Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below.
|
||||
|
||||
|
||||
************
|
||||
Getting Started
|
||||
************
|
||||
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
`Tutor`_ is currently recommended as a development environment for the Authoring
|
||||
MFE. Most likely, it already has this MFE configured; however, you'll need to
|
||||
make some changes in order to run it in development mode. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ for details, or follow the quick
|
||||
guide below.
|
||||
The `devstack`_ is currently recommended as a development environment for your
|
||||
new MFE. If you start it with ``make dev.up.lms`` that should give you
|
||||
everything you need as a companion to this frontend.
|
||||
|
||||
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ to get started using it.
|
||||
|
||||
.. _Devstack: https://github.com/openedx/devstack
|
||||
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
||||
|
||||
|
||||
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>`__.
|
||||
Cloning and Startup
|
||||
===================
|
||||
|
||||
|
||||
1. Clone the repo:
|
||||
|
||||
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
|
||||
|
||||
2. Use node v18.x.
|
||||
|
||||
The current version of the micro-frontend build scripts support node 18.
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes an .nvmrc file to help in setting the
|
||||
correct node version via `nvm use`_.
|
||||
|
||||
3. Install npm dependencies:
|
||||
|
||||
``cd frontend-app-course-authoring && npm install``
|
||||
|
||||
|
||||
4. Start the dev server:
|
||||
|
||||
``npm start``
|
||||
|
||||
|
||||
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
|
||||
or whatever port you setup.
|
||||
|
||||
********
|
||||
Features
|
||||
********
|
||||
|
||||
@@ -109,12 +69,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,22 +125,52 @@ For a particular course, this page allows one to:
|
||||
Feature: New React XBlock Editors
|
||||
=================================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-problem-editor.png
|
||||
|
||||
New React editors for the HTML, Video, and Problem XBlocks are provided here and are rendered by this MFE instead of by the XBlock's authoring view.
|
||||
|
||||
Feature: New Proctoring Exams View
|
||||
==================================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-proctored-exams.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
|
||||
==================================
|
||||
|
||||
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:
|
||||
|
||||
* ``ENABLE_EXAM_SETTINGS_HTML_VIEW``: this feature flag must be enabled for the link to the settings view to be shown
|
||||
|
||||
* `edx-exams <https://github.com/edx/edx-exams>`_: for this feature to work, the ``edx-exams`` IDA must be deployed and its API accessible by the browser
|
||||
|
||||
Configuration
|
||||
@@ -198,76 +190,18 @@ 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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
Feature: Import/Export Pages
|
||||
============================
|
||||
|
||||
.. image:: ./docs/readme-images/feature-export.png
|
||||
|
||||
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.
|
||||
|
||||
|
||||
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
|
||||
========================
|
||||
@@ -278,7 +212,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
|
||||
*********
|
||||
|
||||
@@ -291,8 +225,8 @@ The production build is created with ``npm run build``.
|
||||
:target: https://travis-ci.com/edx/frontend-app-course-authoring
|
||||
.. |Codecov| image:: https://codecov.io/gh/edx/frontend-app-course-authoring/branch/master/graph/badge.svg
|
||||
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-authoring.svg
|
||||
:target: @edx/frontend-app-authoring
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
|
||||
:target: @edx/frontend-app-course-authoring
|
||||
|
||||
Internationalization
|
||||
====================
|
||||
@@ -302,7 +236,6 @@ internationalization.
|
||||
|
||||
.. _frontend-platform i18n howto: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
|
||||
|
||||
|
||||
Getting Help
|
||||
************
|
||||
|
||||
@@ -326,21 +259,6 @@ For more information about these options, see the `Getting Help`_ page.
|
||||
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
|
||||
.. _Getting Help: https://openedx.org/community/connect
|
||||
|
||||
|
||||
Legacy Studio
|
||||
*************
|
||||
|
||||
If you would like to use legacy studio for certain features, you can set the following waffle flags in ``edx-platform``:
|
||||
* ``legacy_studio.text_editor``: loads the legacy HTML Xblock editor when editing a text block
|
||||
* ``legacy_studio.video_editor``: loads the legacy Video editor when editing a video block
|
||||
* ``legacy_studio.problem_editor``: loads the legacy Problem editor when editing a problem block
|
||||
* ``legacy_studio.advanced_settings``: Advanced Settings page
|
||||
* ``legacy_studio.updates``: Updates page
|
||||
* ``legacy_studio.export``: Export page
|
||||
* ``legacy_studio.import``: Import page
|
||||
* ``legacy_studio.files_uploads``: Files page
|
||||
* ``legacy_studio.exam_settings``: loads the legacy Exam Settings
|
||||
|
||||
License
|
||||
*******
|
||||
|
||||
@@ -349,7 +267,6 @@ noted.
|
||||
|
||||
Please see `LICENSE <LICENSE>`_ for details.
|
||||
|
||||
|
||||
Contributing
|
||||
************
|
||||
|
||||
@@ -364,7 +281,6 @@ 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
|
||||
****************************
|
||||
|
||||
@@ -381,7 +297,6 @@ file in this repo.
|
||||
|
||||
.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/frontend-app-course-authoring
|
||||
|
||||
|
||||
Reporting Security Issues
|
||||
*************************
|
||||
|
||||
@@ -395,4 +310,4 @@ Please do not report security issues in public, and email security@openedx.org i
|
||||
|
||||
.. |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
|
||||
:alt: Codecov
|
||||
@@ -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: user:bradenmacdonald
|
||||
type: 'website'
|
||||
lifecycle: 'production'
|
||||
@@ -8,8 +8,3 @@ coverage:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 0%
|
||||
ignore:
|
||||
- "src/grading-settings/grading-scale/react-ranger.js"
|
||||
- "src/generic/DraggableList/verticalSortableList.ts"
|
||||
- "src/container-comparison/data/api.mock.ts"
|
||||
- "src/index.js"
|
||||
|
||||
|
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,21 +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',
|
||||
// This alias is for any code in the src directory that wants to avoid '../../' style relative imports:
|
||||
'^@src/(.*)$': '<rootDir>/src/$1',
|
||||
// This alias is used for plugins in the plugins/ folder only.
|
||||
'^CourseAuthoring/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
modulePathIgnorePatterns: [
|
||||
],
|
||||
});
|
||||
|
||||
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
|
||||
31861
package-lock.json
generated
152
package.json
@@ -1,122 +1,90 @@
|
||||
{
|
||||
"name": "@edx/frontend-app-authoring",
|
||||
"name": "@edx/frontend-app-course-authoring",
|
||||
"version": "0.1.0",
|
||||
"description": "Frontend application template",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/openedx/frontend-app-authoring.git"
|
||||
"url": "git+https://github.com/openedx/frontend-app-course-authoring.git"
|
||||
},
|
||||
"browserslist": [
|
||||
"extends @edx/browserslist-config"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "fedx-scripts formatjs extract --include=plugins",
|
||||
"stylelint": "stylelint \"plugins/**/*.scss\" \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
|
||||
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
|
||||
"lint:fix": "npm run stylelint -- --fix && fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"stylelint": "stylelint \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
|
||||
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"snapshot": "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-markdown": "^6.0.0",
|
||||
"@codemirror/lang-xml": "^6.0.0",
|
||||
"@codemirror/lint": "^6.2.1",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
|
||||
"@edx/browserslist-config": "1.5.0",
|
||||
"@edx/frontend-component-footer": "^14.9.0",
|
||||
"@edx/frontend-component-header": "^8.1.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^7.2.0",
|
||||
"@edx/frontend-platform": "^8.4.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
"@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.6.2",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^23.5.0",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tanstack/react-query": "5.90.5",
|
||||
"@tinymce/tinymce-react": "^6.0.0",
|
||||
"classnames": "2.5.1",
|
||||
"codemirror": "^6.0.0",
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "12.0.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^1.2.1",
|
||||
"@edx/frontend-lib-content-components": "^1.169.3",
|
||||
"@edx/frontend-platform": "4.2.0",
|
||||
"@edx/paragon": "^20.45.4",
|
||||
"@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": "^5.0.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"formik": "2.4.6",
|
||||
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
|
||||
"formik": "2.2.6",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"meilisearch": "^0.41.0",
|
||||
"moment": "2.30.1",
|
||||
"moment-shortformat": "^2.1.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"moment": "2.29.2",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.14.0",
|
||||
"react-datepicker": "^4.13.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"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.30.1",
|
||||
"react-router-dom": "6.30.1",
|
||||
"react-select": "5.10.2",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-transition-group": "4.4.5",
|
||||
"redux": "4.2.1",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.4.1",
|
||||
"reselect": "^4.1.5",
|
||||
"tinymce": "^5.10.4",
|
||||
"universal-cookie": "^8.0.0",
|
||||
"uuid": "^11.1.0",
|
||||
"xmlchecker": "^0.1.0",
|
||||
"yup": "0.32.11"
|
||||
"react-ranger": "^2.1.0",
|
||||
"react-redux": "7.1.3",
|
||||
"react-responsive": "8.1.0",
|
||||
"react-router": "5.2.0",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-textarea-autosize": "^8.4.1",
|
||||
"react-transition-group": "4.4.1",
|
||||
"redux": "4.0.5",
|
||||
"regenerator-runtime": "0.13.7",
|
||||
"uuid": "^3.4.0",
|
||||
"yup": "0.31.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"axios-mock-adapter": "2.1.0",
|
||||
"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"
|
||||
"@edx/browserslist-config": "1.0.0",
|
||||
"@edx/frontend-build": "12.8.6",
|
||||
"@edx/reactifex": "^1.0.3",
|
||||
"@edx/stylelint-config-edx": "^2.3.0",
|
||||
"@testing-library/jest-dom": "5.16.4",
|
||||
"@testing-library/react": "12.1.1",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"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,49 +0,0 @@
|
||||
import React from 'react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import { initializeMocks, render } from 'CourseAuthoring/testUtils';
|
||||
import LearningAssistantSettings from './Settings';
|
||||
|
||||
const onClose = () => { };
|
||||
|
||||
describe('Learning Assistant Settings', () => {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
initializeMocks({ initialState });
|
||||
render(<LearningAssistantSettings onClose={onClose} />);
|
||||
|
||||
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,49 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const LiveCommonFields = ({
|
||||
values,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<>
|
||||
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
|
||||
<FormikControl
|
||||
name="consumerKey"
|
||||
value={values.consumerKey}
|
||||
floatingLabel={intl.formatMessage(messages.consumerKey)}
|
||||
className="pb-1"
|
||||
type="input"
|
||||
/>
|
||||
<FormikControl
|
||||
name="consumerSecret"
|
||||
value={values.consumerSecret}
|
||||
floatingLabel={intl.formatMessage(messages.consumerSecret)}
|
||||
className="pb-1"
|
||||
type="password"
|
||||
/>
|
||||
<FormikControl
|
||||
name="launchUrl"
|
||||
value={values.launchUrl}
|
||||
floatingLabel={intl.formatMessage(messages.launchUrl)}
|
||||
className="pb-1"
|
||||
type="input"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
LiveCommonFields.propTypes = {
|
||||
values: PropTypes.shape({
|
||||
consumerKey: PropTypes.string,
|
||||
consumerSecret: PropTypes.string,
|
||||
launchUrl: PropTypes.string,
|
||||
launchEmail: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default LiveCommonFields;
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import FormikControl from 'CourseAuthoring/generic/FormikControl';
|
||||
|
||||
import messages from './messages';
|
||||
import { providerNames } from './constants';
|
||||
import LiveCommonFields from './LiveCommonFields';
|
||||
|
||||
const ZoomSettings = ({
|
||||
values,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
<>
|
||||
{!values.piiSharingEnable ? (
|
||||
<p data-testid="request-pii-sharing">
|
||||
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{(values.piiSharingEmail || values.piiSharingUsername)
|
||||
&& (
|
||||
<p data-testid="helper-text">
|
||||
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
|
||||
</p>
|
||||
)}
|
||||
<LiveCommonFields values={values} />
|
||||
<FormikControl
|
||||
name="launchEmail"
|
||||
value={values.launchEmail}
|
||||
floatingLabel={intl.formatMessage(messages.launchEmail)}
|
||||
type="input"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ZoomSettings.propTypes = {
|
||||
values: PropTypes.shape({
|
||||
consumerKey: PropTypes.string,
|
||||
consumerSecret: PropTypes.string,
|
||||
launchUrl: PropTypes.string,
|
||||
launchEmail: PropTypes.string,
|
||||
provider: PropTypes.string,
|
||||
piiSharingEmail: PropTypes.bool,
|
||||
piiSharingUsername: PropTypes.bool,
|
||||
piiSharingEnable: PropTypes.bool,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default ZoomSettings;
|
||||
@@ -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,155 +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 () => {
|
||||
await mockStore({ apiStatus: 200, enabled: true });
|
||||
renderComponent();
|
||||
|
||||
const checkbox = await screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
|
||||
expect(checkbox).toBeChecked();
|
||||
|
||||
await waitFor(() => {
|
||||
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
||||
const enableBadge = screen.getByTestId('enable-badge');
|
||||
|
||||
expect(label).toBeVisible();
|
||||
|
||||
expect(enableBadge).toHaveTextContent('Enabled');
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays title, helper text and hides badge when flexible peer grading button is disabled', async () => {
|
||||
renderComponent();
|
||||
await mockStore({ apiStatus: 200, enabled: false });
|
||||
|
||||
const label = await screen.findByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
||||
const enableBadge = screen.queryByTestId('enable-badge');
|
||||
|
||||
expect(label).toBeVisible();
|
||||
|
||||
expect(enableBadge).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
export const courseId = 'course-v1:org+num+run';
|
||||
|
||||
export const inititalState = {
|
||||
courseDetail: {
|
||||
courseId,
|
||||
status: 'successful',
|
||||
},
|
||||
pagesAndResources: {
|
||||
courseAppIds: ['ora_settings'],
|
||||
loadingStatus: 'in-progress',
|
||||
savingStatus: '',
|
||||
courseAppsApiStatus: {},
|
||||
courseAppSettings: {},
|
||||
},
|
||||
models: {
|
||||
courseApps: {
|
||||
ora_settings: {
|
||||
id: 'ora_settings',
|
||||
name: 'Flexible Peer Grading',
|
||||
enabled: true,
|
||||
description: 'Enable flexible peer grading',
|
||||
allowedOperations: {
|
||||
enable: false,
|
||||
configure: true,
|
||||
},
|
||||
documentationLinks: {
|
||||
learnMoreConfiguration: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.pages-resources.ora.heading',
|
||||
defaultMessage: 'Configure Flexible Peer Grading',
|
||||
description: 'Title for the modal dialog header',
|
||||
},
|
||||
ORASettingsHelpLink: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.link',
|
||||
defaultMessage: 'Learn more about open response assessment settings',
|
||||
description: 'Descriptive text for the hyperlink to the docs site',
|
||||
},
|
||||
enableFlexPeerGradeLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.label',
|
||||
defaultMessage: 'Flex Peer Grading',
|
||||
description: 'Label for form switch',
|
||||
},
|
||||
enableFlexPeerGradeHelp: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.help',
|
||||
defaultMessage: 'Turn on Flexible Peer Grading for all open response assessments in the course with peer grading.',
|
||||
description: 'Help text describing what happens when the switch is enabled',
|
||||
},
|
||||
enabledBadgeLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.enabled-badge.label',
|
||||
defaultMessage: 'Enabled',
|
||||
description: 'Label for badge that show users that a setting is enabled',
|
||||
},
|
||||
cancelLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.cancel-button.label',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Label for button that cancels user changes',
|
||||
},
|
||||
saveLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-button.label',
|
||||
defaultMessage: 'Save',
|
||||
description: 'Label for button that saves user changes',
|
||||
},
|
||||
pendingSaveLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.pending-save-button.label',
|
||||
defaultMessage: 'Saving',
|
||||
description: 'Label for button that has pending api save calls',
|
||||
},
|
||||
errorSavingTitle: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.title',
|
||||
defaultMessage: 'We couldn\'t apply your changes.',
|
||||
},
|
||||
errorSavingMessage: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.message',
|
||||
defaultMessage: 'Please check your entries and try again.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-ora_settings",
|
||||
"version": "0.1.0",
|
||||
"description": "Open Response Assessment configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"react-redux": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-proctoring",
|
||||
"version": "0.1.0",
|
||||
"description": "Proctoring configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"classnames": "*",
|
||||
"email-validator": "*",
|
||||
"react": "*",
|
||||
"prop-types": "*",
|
||||
"moment": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-progress",
|
||||
"version": "0.1.0",
|
||||
"description": "Progress configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { useFormikContext } from 'formik';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import GroupEditor from './GroupEditor';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('formik', () => ({
|
||||
...jest.requireActual('formik'),
|
||||
useFormikContext: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('GroupEditor', () => {
|
||||
const mockIntl = { formatMessage: jest.fn() };
|
||||
|
||||
const mockGroup = {
|
||||
id: '1',
|
||||
name: 'Test Group',
|
||||
description: 'Test Group Description',
|
||||
type: 'open',
|
||||
maxTeamSize: 5,
|
||||
};
|
||||
|
||||
const mockProps = {
|
||||
intl: mockIntl,
|
||||
fieldNameCommonBase: 'test',
|
||||
group: mockGroup,
|
||||
onDelete: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
onBlur: jest.fn(),
|
||||
errors: {},
|
||||
};
|
||||
|
||||
const renderComponent = (overrideProps = {}) => render(
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<GroupEditor {...mockProps} {...overrideProps} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
useFormikContext.mockReturnValue({
|
||||
touched: {},
|
||||
errors: {},
|
||||
handleChange: jest.fn(),
|
||||
handleBlur: jest.fn(),
|
||||
setFieldError: jest.fn(),
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders without errors', () => {
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test('renders the group name and description', () => {
|
||||
const { getByText } = renderComponent();
|
||||
expect(getByText('Test Group')).toBeInTheDocument();
|
||||
expect(getByText('Test Group Description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('group types messages', () => {
|
||||
test('group type open message', () => {
|
||||
const { getByLabelText, getByText } = renderComponent();
|
||||
const expandButton = getByLabelText('Expand group editor');
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
fireEvent.click(expandButton);
|
||||
expect(getByText(messages.groupTypeOpenDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('group type public_managed message', () => {
|
||||
const publicManagedGroupMock = {
|
||||
id: '2',
|
||||
name: 'Test Group',
|
||||
description: 'Test Group Description',
|
||||
type: 'public_managed',
|
||||
maxTeamSize: 5,
|
||||
};
|
||||
const { getByLabelText, getByText } = renderComponent({ group: publicManagedGroupMock });
|
||||
const expandButton = getByLabelText('Expand group editor');
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
fireEvent.click(expandButton);
|
||||
expect(getByText(messages.groupTypePublicManagedDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('group type private_managed message', () => {
|
||||
const privateManagedGroupMock = {
|
||||
id: '3',
|
||||
name: 'Test Group',
|
||||
description: 'Test Group Description',
|
||||
type: 'private_managed',
|
||||
maxTeamSize: 5,
|
||||
};
|
||||
const { getByLabelText, getByText } = renderComponent({ group: privateManagedGroupMock });
|
||||
const expandButton = getByLabelText('Expand group editor');
|
||||
expect(expandButton).toBeInTheDocument();
|
||||
fireEvent.click(expandButton);
|
||||
expect(getByText(messages.groupTypePrivateManagedDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-teams",
|
||||
"version": "0.1.0",
|
||||
"description": "Teams configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"formik": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"uuid": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
||||
|
||||
/**
|
||||
* Check if a group type is enabled by the current configuration.
|
||||
* This is a temporary workaround to disable the OPEN MANAGED team type until it is fully adopted.
|
||||
* For more information, see: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3885760525/Open+Managed+Group+Type
|
||||
* @param {string} groupType - the group type to check
|
||||
* @returns {boolean} - true if the group type is enabled
|
||||
*/
|
||||
export const isGroupTypeEnabled = (groupType) => {
|
||||
const enabledTypesByDefault = [
|
||||
GroupTypes.OPEN,
|
||||
GroupTypes.PUBLIC_MANAGED,
|
||||
GroupTypes.PRIVATE_MANAGED,
|
||||
];
|
||||
const enabledTypesByConfig = {
|
||||
[GroupTypes.OPEN_MANAGED]: getConfig().ENABLE_OPEN_MANAGED_TEAM_TYPE,
|
||||
};
|
||||
return enabledTypesByDefault.includes(groupType) || enabledTypesByConfig[groupType] || false;
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { GroupTypes } from 'CourseAuthoring/data/constants';
|
||||
import { isGroupTypeEnabled } from './utils';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() }));
|
||||
|
||||
describe('teams utils', () => {
|
||||
describe('isGroupTypeEnabled', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('returns true if the group type is enabled', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false });
|
||||
expect(isGroupTypeEnabled(GroupTypes.OPEN)).toBe(true);
|
||||
expect(isGroupTypeEnabled(GroupTypes.PUBLIC_MANAGED)).toBe(true);
|
||||
expect(isGroupTypeEnabled(GroupTypes.PRIVATE_MANAGED)).toBe(true);
|
||||
});
|
||||
test('returns false if the OPEN_MANAGED group is not enabled', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: false });
|
||||
expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true if the OPEN_MANAGED group is enabled', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
|
||||
expect(isGroupTypeEnabled(GroupTypes.OPEN_MANAGED)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false if the group is invalid', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
|
||||
expect(isGroupTypeEnabled('FOO')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false if the group is null', () => {
|
||||
getConfig.mockReturnValue({ ENABLE_OPEN_MANAGED_TEAM_TYPE: true });
|
||||
expect(isGroupTypeEnabled(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-wiki",
|
||||
"version": "0.1.0",
|
||||
"description": "Wiki configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
Xpert Unit Summaries Configuration Plugin
|
||||
=========================================
|
||||
|
||||
Install this using ``npm install plugins/course-apps/xpert_unit_summary/ --no-save``.
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "@openedx-plugins/course-app-xpert_unit_summary",
|
||||
"version": "0.1.0",
|
||||
"description": "Xpert Unit Summaries configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"formik": "*",
|
||||
"prop-types": "*",
|
||||
"yup": "*",
|
||||
"react": "*",
|
||||
"react-redux": "*",
|
||||
"react-router-dom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,17 +5,53 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import { StudioFooterSlot } from '@edx/frontend-component-footer';
|
||||
import Header from './header';
|
||||
import { Footer } from '@edx/frontend-lib-content-components';
|
||||
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 { 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
|
||||
marketingBaseUrl={process.env.MARKETING_SITE_BASE_URL}
|
||||
termsOfServiceUrl={process.env.TERMS_OF_SERVICE_URL}
|
||||
privacyPolicyUrl={process.env.PRIVACY_POLICY_URL}
|
||||
supportEmail={process.env.SUPPORT_EMAIL}
|
||||
platformName={process.env.SITE_NAME}
|
||||
lmsBaseUrl={process.env.LMS_BASE_URL}
|
||||
studioBaseUrl={process.env.STUDIO_BASE_URL}
|
||||
showAccessibilityPage={process.env.ENABLE_ACCESSIBILITY_PAGE === 'true'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -23,49 +59,39 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
dispatch(fetchCourseDetail(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(state => state.courseDetail.status) === RequestStatus.IN_PROGRESS;
|
||||
const { pathname } = useLocation();
|
||||
const isEditor = pathname.includes('/editor');
|
||||
const showHeader = !pathname.includes('/editor');
|
||||
|
||||
if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) {
|
||||
return (
|
||||
<NotFoundAlert />
|
||||
);
|
||||
}
|
||||
if (courseAppsApiStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
|
||||
{/* While V2 Editors are temporarily served from their own pages
|
||||
using url pattern containing /editor/,
|
||||
we shouldn't have the header and footer on these pages.
|
||||
This functionality will be removed in TNL-9591 */}
|
||||
{inProgress ? !isEditor && <Loading />
|
||||
: (!isEditor && (
|
||||
<Header
|
||||
number={courseNumber}
|
||||
org={courseOrg}
|
||||
title={courseTitle}
|
||||
contextId={courseId}
|
||||
{inProgress ? showHeader && <Loading />
|
||||
: (showHeader && (
|
||||
<AppHeader
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{children}
|
||||
{!inProgress && !isEditor && <StudioFooterSlot />}
|
||||
{!inProgress && showHeader && <AppFooter />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import React from 'react';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import initializeStore from './store';
|
||||
import CourseAuthoringPage from './CourseAuthoringPage';
|
||||
import PagesAndResources from './pages-and-resources/PagesAndResources';
|
||||
import { executeThunk } from './utils';
|
||||
import { fetchCourseApps } from './pages-and-resources/data/thunks';
|
||||
import { fetchCourseDetail } from './data/thunks';
|
||||
import { getApiWaffleFlagsUrl } from './data/api';
|
||||
import { initializeMocks, render } from './testUtils';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
let mockPathname = '/evilguy/';
|
||||
@@ -19,15 +24,6 @@ jest.mock('react-router-dom', () => ({
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mocks = initializeMocks();
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
axiosMock
|
||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
||||
.reply(200, {});
|
||||
});
|
||||
|
||||
describe('Editor Pages Load no header', () => {
|
||||
const mockStoreSuccess = async () => {
|
||||
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
@@ -37,13 +33,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();
|
||||
@@ -52,69 +64,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();
|
||||
});
|
||||
const mockStoreDenied = async () => {
|
||||
const studioApiBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const courseAppsApiUrl = `${studioApiBaseUrl}/api/course_apps/v1/apps`;
|
||||
|
||||
axiosMock.onGet(
|
||||
`${courseAppsApiUrl}/${courseId}`,
|
||||
).reply(403);
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
};
|
||||
test('renders PermissionDeniedAlert when courseAppsApiStatus is DENIED', async () => {
|
||||
mockPathname = '/editor/';
|
||||
await mockStoreDenied();
|
||||
|
||||
const wrapper = render(<CourseAuthoringPage courseId={courseId} />);
|
||||
expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
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 './textbooks';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Switch, useRouteMatch } from 'react-router';
|
||||
import { PageRoute } from '@edx/frontend-platform/react';
|
||||
import Placeholder from '@edx/frontend-lib-content-components';
|
||||
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 FilesAndUploads from './files-and-uploads';
|
||||
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, SubsectionUnitRedirect } 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,110 +32,91 @@ 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>}
|
||||
/>
|
||||
<Route
|
||||
path="/subsection/:subsectionId"
|
||||
element={<PageWrap><SubsectionUnitRedirect courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
element={<PageWrap><IframeProvider><CourseUnit courseId={courseId} /></IframeProvider></PageWrap>}
|
||||
/>
|
||||
))}
|
||||
<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}/outline`}>
|
||||
{process.env.ENABLE_NEW_COURSE_OUTLINE_PAGE === 'true'
|
||||
&& (
|
||||
<Placeholder />
|
||||
)}
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/course_info`}>
|
||||
<CourseUpdates courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/assets`}>
|
||||
<FilesAndUploads courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/videos`}>
|
||||
{process.env.ENABLE_NEW_VIDEO_UPLOAD_PAGE === 'true'
|
||||
&& (
|
||||
<Placeholder />
|
||||
)}
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/pages-and-resources`}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/proctored-exam-settings`}>
|
||||
<ProctoredExamSettings courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/custom-pages`}>
|
||||
<CustomPages courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/container/:blockId`}>
|
||||
{process.env.ENABLE_UNIT_PAGE === 'true'
|
||||
&& (
|
||||
<Placeholder />
|
||||
)}
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/editor/course-videos/:blockId`}>
|
||||
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
|
||||
&& (
|
||||
<VideoSelectorContainer
|
||||
courseId={courseId}
|
||||
/>
|
||||
)}
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/editor/:blockType/:blockId?`}>
|
||||
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
|
||||
&& (
|
||||
<EditorContainer
|
||||
courseId={courseId}
|
||||
/>
|
||||
)}
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/settings/details`}>
|
||||
<ScheduleAndDetails courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/settings/grading`}>
|
||||
<GradingSettings courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/course_team`}>
|
||||
<CourseTeam courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/settings/advanced`}>
|
||||
<AdvancedSettings courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/import`}>
|
||||
{process.env.ENABLE_NEW_IMPORT_PAGE === 'true'
|
||||
&& (
|
||||
<Placeholder />
|
||||
)}
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/export`}>
|
||||
{process.env.ENABLE_NEW_EXPORT_PAGE === 'true'
|
||||
&& (
|
||||
<Placeholder />
|
||||
)}
|
||||
</PageRoute>
|
||||
</Switch>
|
||||
</CourseAuthoringPage>
|
||||
);
|
||||
};
|
||||
|
||||
CourseAuthoringRoutes.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CourseAuthoringRoutes;
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import React from 'react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
|
||||
import { getApiWaffleFlagsUrl } from './data/api';
|
||||
import {
|
||||
screen, initializeMocks, render, waitFor,
|
||||
} from './testUtils';
|
||||
import initializeStore from './store';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const pagesAndResourcesMockText = 'Pages And Resources';
|
||||
const proctoredExamSeetingsMockText = 'Proctored Exam Settings';
|
||||
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>,
|
||||
// Mock the TinyMceWidget from frontend-lib-content-components
|
||||
jest.mock('@edx/frontend-lib-content-components', () => ({
|
||||
TinyMceWidget: () => <div>Widget</div>,
|
||||
Footer: () => <div>Footer</div>,
|
||||
prepareEditorRef: jest.fn(() => ({
|
||||
refReady: true,
|
||||
@@ -29,10 +25,20 @@ jest.mock('./editors/sharedComponents/TinyMceWidget', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useRouteMatch: () => ({
|
||||
path: `/course/${courseId}`,
|
||||
}),
|
||||
}));
|
||||
jest.mock('./pages-and-resources/PagesAndResources', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return pagesAndResourcesMockText;
|
||||
});
|
||||
jest.mock('./proctored-exam-settings/ProctoredExamSettings', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return proctoredExamSeetingsMockText;
|
||||
});
|
||||
jest.mock('./editors/EditorContainer', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return editorContainerMockText;
|
||||
@@ -47,57 +53,91 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
|
||||
});
|
||||
|
||||
describe('<CourseAuthoringRoutes>', () => {
|
||||
beforeEach(async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
axiosMock
|
||||
.onGet(getApiWaffleFlagsUrl(courseId))
|
||||
.reply(200, {});
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it('renders the PagesAndResources component when the pages and resources route is active', async () => {
|
||||
// TODO: This test needs to be corrected.
|
||||
// The problem arose after moving new commits (https://github.com/raccoongang/frontend-app-course-authoring/pull/25)
|
||||
it.skip('renders the PagesAndResources component when the pages and resources route is active', () => {
|
||||
render(
|
||||
<CourseAuthoringRoutes />,
|
||||
{ routerProps: { initialEntries: ['/pages-and-resources'] } },
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={[`/course/${courseId}/pages-and-resources`]}>
|
||||
<CourseAuthoringRoutes courseId={courseId} />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(proctoredExamSeetingsMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the EditorContainer component when the course editor route is active', async () => {
|
||||
// TODO: This test needs to be corrected.
|
||||
// The problem arose after moving new commits (https://github.com/raccoongang/frontend-app-course-authoring/pull/25)
|
||||
it.skip('renders the ProctoredExamSettings component when the proctored exam settings route is active', () => {
|
||||
render(
|
||||
<CourseAuthoringRoutes />,
|
||||
{ routerProps: { initialEntries: ['/editor/video/block-id'] } },
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={[`/course/${courseId}/proctored-exam-settings`]}>
|
||||
<CourseAuthoringRoutes courseId={courseId} />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(proctoredExamSeetingsMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
learningContextId: courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the VideoSelectorContainer component when the course videos route is active', async () => {
|
||||
it('renders the EditorContainer component when the course editor route is active', () => {
|
||||
render(
|
||||
<CourseAuthoringRoutes />,
|
||||
{ routerProps: { initialEntries: ['/editor/course-videos/block-id'] } },
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={[`/course/${courseId}/editor/video/block-id`]}>
|
||||
<CourseAuthoringRoutes courseId={courseId} />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the VideoSelectorContainer component when the course videos route is active', () => {
|
||||
render(
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={[`/course/${courseId}/editor/course-videos/block-id`]}>
|
||||
<CourseAuthoringRoutes courseId={courseId} />
|
||||
</MemoryRouter>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
|
||||
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
|
||||
expect(mockComponentFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
courseId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
export default {
|
||||
content: {
|
||||
id: 67,
|
||||
userId: 3,
|
||||
created: '2024-01-16T13:09:11.540615Z',
|
||||
purpose: 'clipboard',
|
||||
status: 'ready',
|
||||
blockType: 'chapter',
|
||||
blockTypeDisplay: 'Section',
|
||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
|
||||
displayName: 'Chapter 1',
|
||||
},
|
||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
|
||||
sourceContextTitle: 'Demonstration Course',
|
||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
export default {
|
||||
content: {
|
||||
id: 67,
|
||||
userId: 3,
|
||||
created: '2024-01-16T13:09:11.540615Z',
|
||||
purpose: 'clipboard',
|
||||
status: 'ready',
|
||||
blockType: 'sequential',
|
||||
blockTypeDisplay: 'Subsection',
|
||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
|
||||
displayName: 'Sequences',
|
||||
},
|
||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
|
||||
sourceContextTitle: 'Demonstration Course',
|
||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
export default {
|
||||
content: {
|
||||
id: 67,
|
||||
userId: 3,
|
||||
created: '2024-01-16T13:09:11.540615Z',
|
||||
purpose: 'clipboard',
|
||||
status: 'ready',
|
||||
blockType: 'vertical',
|
||||
blockTypeDisplay: 'Unit',
|
||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
|
||||
displayName: 'Introduction: Video and Sequences',
|
||||
},
|
||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
||||
sourceContextTitle: 'Demonstration Course',
|
||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
export default {
|
||||
content: {
|
||||
id: 69,
|
||||
userId: 3,
|
||||
created: '2024-01-16T13:33:21.314439Z',
|
||||
purpose: 'clipboard',
|
||||
status: 'ready',
|
||||
blockType: 'html',
|
||||
blockTypeDisplay: 'Text',
|
||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/69/olx',
|
||||
displayName: 'Blank HTML Page',
|
||||
},
|
||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@html+block@html1',
|
||||
sourceContextTitle: 'Demonstration Course',
|
||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1',
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as clipboardUnit } from './clipboardUnit';
|
||||
export { default as clipboardSubsection } from './clipboardSubsection';
|
||||
export { default as clipboardXBlock } from './clipboardXBlock';
|
||||
export { default as clipboardSection } from './clipboardSection';
|
||||
@@ -1,98 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { 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 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,143 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedMessage, FormattedDate, FormattedTime, useIntl,
|
||||
} 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,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
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,
|
||||
};
|
||||
|
||||
export default AccessibilityForm;
|
||||
@@ -1,164 +0,0 @@
|
||||
import {
|
||||
render,
|
||||
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;
|
||||
let user;
|
||||
beforeEach(async () => {
|
||||
user = userEvent.setup();
|
||||
renderComponent();
|
||||
formSections = screen.getAllByRole('textbox');
|
||||
|
||||
await user.type(formSections[0], 'email@email.com');
|
||||
await user.type(formSections[1], 'test name');
|
||||
await user.type(formSections[2], 'feedback message');
|
||||
|
||||
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||
});
|
||||
|
||||
it('shows correct success message', async () => {
|
||||
axiosMock.onPost(getZendeskrUrl()).reply(200);
|
||||
|
||||
await user.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 user.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;
|
||||
let user;
|
||||
beforeEach(async () => {
|
||||
user = userEvent.setup();
|
||||
renderComponent();
|
||||
formSections = screen.getAllByRole('textbox');
|
||||
|
||||
await user.type(formSections[0], 'email@email.com');
|
||||
await user.type(formSections[1], 'test name');
|
||||
await user.type(formSections[2], 'feedback message');
|
||||
|
||||
submitButton = screen.getByText(messages.accessibilityPolicyFormSubmitLabel.defaultMessage);
|
||||
});
|
||||
|
||||
it('adds validation checking on each input field', async () => {
|
||||
await user.clear(formSections[0]);
|
||||
await user.clear(formSections[1]);
|
||||
await user.clear(formSections[2]);
|
||||
|
||||
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 user.clear(formSections[0]);
|
||||
await user.clear(formSections[1]);
|
||||
await user.clear(formSections[2]);
|
||||
await user.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,39 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Container } from '@openedx/paragon';
|
||||
import { StudioFooterSlot } from '@edx/frontend-component-footer';
|
||||
|
||||
import Header from '../header';
|
||||
import messages from './messages';
|
||||
import AccessibilityBody from './AccessibilityBody';
|
||||
import AccessibilityForm from './AccessibilityForm';
|
||||
|
||||
import { COMMUNITY_ACCESSIBILITY_LINK, ACCESSIBILITY_EMAIL } from './constants';
|
||||
|
||||
const AccessibilityPage = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{intl.formatMessage(messages.pageTitle, {
|
||||
siteName: process.env.SITE_NAME,
|
||||
})}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Header isHiddenMainMenu />
|
||||
<Container size="xl" classNamae="px-4">
|
||||
<AccessibilityBody
|
||||
{...{ email: ACCESSIBILITY_EMAIL, communityAccessibilityLink: COMMUNITY_ACCESSIBILITY_LINK }}
|
||||
/>
|
||||
<AccessibilityForm accessibilityEmail={ACCESSIBILITY_EMAIL} />
|
||||
</Container>
|
||||
<StudioFooterSlot />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AccessibilityPage.propTypes = {};
|
||||
|
||||
export default AccessibilityPage;
|
||||
@@ -1,17 +0,0 @@
|
||||
// @ts-check
|
||||
import { initializeMocks, render, screen } from '../testUtils';
|
||||
import AccessibilityPage from './index';
|
||||
|
||||
const renderComponent = () => render(<AccessibilityPage />);
|
||||
|
||||
describe('<AccessibilityPolicyPage />', () => {
|
||||
describe('renders', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMocks();
|
||||
});
|
||||
it('contains the policy body', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText('Individualized Accessibility Process for Course Creators')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export const COMMUNITY_ACCESSIBILITY_LINK = 'https://www.edx.org/accessibility';
|
||||
export const ACCESSIBILITY_EMAIL = 'accessibility@edx.org';
|
||||
@@ -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,24 +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) {
|
||||
/* istanbul ignore else */
|
||||
if (error.response && error.response.status === 429) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
} else {
|
||||
/* istanbul ignore next */
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default submitAccessibilityForm;
|
||||
@@ -1,3 +0,0 @@
|
||||
import AccessibilityPage from './AccessibilityPage';
|
||||
|
||||
export default AccessibilityPage;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
pageTitle: {
|
||||
id: 'course-authoring.accessibility.page.title',
|
||||
defaultMessage: 'Studio Accessibility Policy| {siteName}',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -3,13 +3,12 @@ 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, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import Placeholder from '../editors/Placeholder';
|
||||
} from '@edx/paragon';
|
||||
import { CheckCircle, Info, Warning } from '@edx/paragon/icons';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import Placeholder from '@edx/frontend-lib-content-components';
|
||||
|
||||
import AlertProctoringError from '../generic/AlertProctoringError';
|
||||
import { useModel } from '../generic/model-store';
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import { parseArrayOrObjectValues } from '../utils';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
@@ -24,11 +23,11 @@ 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';
|
||||
import { useAdvancedSettings } from './hooks';
|
||||
|
||||
const AdvancedSettings = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
const AdvancedSettings = ({ intl, courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
|
||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||
const [errorModal, showErrorModal] = useState(false);
|
||||
@@ -39,46 +38,26 @@ const AdvancedSettings = ({ courseId }) => {
|
||||
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 {
|
||||
advancedSettingsData,
|
||||
isLoading,
|
||||
updateSettingsButtonState,
|
||||
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]);
|
||||
loadingSettingsStatus,
|
||||
savingStatus,
|
||||
} = useAdvancedSettings({
|
||||
dispatch,
|
||||
courseId,
|
||||
intl,
|
||||
setIsQueryPending,
|
||||
setShowSuccessAlert,
|
||||
setIsEditableState,
|
||||
showSaveSettingsPrompt,
|
||||
showErrorModal,
|
||||
setErrorFields,
|
||||
hasInternetConnectionError,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
@@ -86,7 +65,7 @@ const AdvancedSettings = ({ courseId }) => {
|
||||
}
|
||||
if (loadingSettingsStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<div className="row justify-content-center m-6">
|
||||
<div className="row justify-contnt-center m-6">
|
||||
<Placeholder />
|
||||
</div>
|
||||
);
|
||||
@@ -131,7 +110,7 @@ const AdvancedSettings = ({ courseId }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container size="xl" className="advanced-settings px-4">
|
||||
<Container size="xl" className="px-4">
|
||||
<div className="setting-header mt-5">
|
||||
{(proctoringErrors?.length > 0) && (
|
||||
<AlertProctoringError
|
||||
@@ -158,6 +137,11 @@ const AdvancedSettings = ({ courseId }) => {
|
||||
) : null}
|
||||
</TransitionReplace>
|
||||
</div>
|
||||
<SubHeader
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
title={intl.formatMessage(messages.headingTitle)}
|
||||
contentTitle={intl.formatMessage(messages.policy)}
|
||||
/>
|
||||
<section className="setting-items mb-4">
|
||||
<Layout
|
||||
lg={[{ span: 9 }, { span: 3 }]}
|
||||
@@ -167,11 +151,6 @@ const AdvancedSettings = ({ courseId }) => {
|
||||
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">
|
||||
@@ -279,7 +258,8 @@ const AdvancedSettings = ({ courseId }) => {
|
||||
};
|
||||
|
||||
AdvancedSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default AdvancedSettings;
|
||||
export default injectIntl(AdvancedSettings);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
render as baseRender,
|
||||
fireEvent,
|
||||
initializeMocks,
|
||||
waitFor,
|
||||
} from '../testUtils';
|
||||
import React from 'react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import initializeStore from '../store';
|
||||
import { executeThunk } from '../utils';
|
||||
import { advancedSettingsMock } from './__mocks__';
|
||||
import { getCourseAdvancedSettingsApiUrl } from './data/api';
|
||||
@@ -25,22 +28,39 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
/>
|
||||
)));
|
||||
|
||||
const render = () => baseRender(
|
||||
<AdvancedSettings courseId={courseId} />,
|
||||
{ path: mockPathname },
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
pathname: mockPathname,
|
||||
}),
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<AdvancedSettings intl={injectIntl} courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<AdvancedSettings />', () => {
|
||||
beforeEach(() => {
|
||||
const mocks = initializeMocks();
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
|
||||
.reply(200, advancedSettingsMock);
|
||||
});
|
||||
it('should render without errors', async () => {
|
||||
const { getByText } = render();
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
|
||||
@@ -52,7 +72,7 @@ describe('<AdvancedSettings />', () => {
|
||||
});
|
||||
});
|
||||
it('should render setting element', async () => {
|
||||
const { getByText, queryByText } = render();
|
||||
const { getByText, queryByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const advancedModuleListTitle = getByText(/Advanced Module List/i);
|
||||
expect(advancedModuleListTitle).toBeInTheDocument();
|
||||
@@ -60,7 +80,7 @@ describe('<AdvancedSettings />', () => {
|
||||
});
|
||||
});
|
||||
it('should change to onСhange', async () => {
|
||||
const { getByLabelText } = render();
|
||||
const { getByLabelText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const textarea = getByLabelText(/Advanced Module List/i);
|
||||
expect(textarea).toBeInTheDocument();
|
||||
@@ -69,7 +89,7 @@ describe('<AdvancedSettings />', () => {
|
||||
});
|
||||
});
|
||||
it('should display a warning alert', async () => {
|
||||
const { getByLabelText, getByText } = render();
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const textarea = getByLabelText(/Advanced Module List/i);
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
@@ -80,7 +100,7 @@ describe('<AdvancedSettings />', () => {
|
||||
});
|
||||
});
|
||||
it('should display a tooltip on clicking on the icon', async () => {
|
||||
const { getByLabelText, getByText } = render();
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const button = getByLabelText(/Show help text/i);
|
||||
fireEvent.click(button);
|
||||
@@ -88,7 +108,7 @@ describe('<AdvancedSettings />', () => {
|
||||
});
|
||||
});
|
||||
it('should change deprecated button text ', async () => {
|
||||
const { getByText } = render();
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
|
||||
expect(showDeprecatedItemsBtn).toBeInTheDocument();
|
||||
@@ -98,7 +118,7 @@ describe('<AdvancedSettings />', () => {
|
||||
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
|
||||
});
|
||||
it('should reset to default value on click on Cancel button', async () => {
|
||||
const { getByLabelText, getByText } = render();
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
@@ -109,7 +129,7 @@ describe('<AdvancedSettings />', () => {
|
||||
expect(textarea.value).toBe('[]');
|
||||
});
|
||||
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
|
||||
const { getByLabelText, getByText } = render();
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
@@ -121,7 +141,7 @@ describe('<AdvancedSettings />', () => {
|
||||
expect(textarea.value).toBe('[3, 2, 1,');
|
||||
});
|
||||
it('should show success alert after save', async () => {
|
||||
const { getByLabelText, getByText } = render();
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as advancedSettingsMock } from './advancedSettings';
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import {
|
||||
camelCaseObject,
|
||||
getConfig,
|
||||
} from '@edx/frontend-platform';
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { camelCase } from 'lodash';
|
||||
import { convertObjectToSnakeCase } from '../../utils';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
@@ -19,19 +15,7 @@ const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/
|
||||
export async function getCourseAdvancedSettings(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
|
||||
const keepValues = {};
|
||||
Object.keys(data).forEach((key) => {
|
||||
keepValues[camelCase(key)] = { value: data[key].value };
|
||||
});
|
||||
const formattedData = {};
|
||||
const formattedCamelCaseData = camelCaseObject(data);
|
||||
Object.keys(formattedCamelCaseData).forEach((key) => {
|
||||
formattedData[key] = {
|
||||
...formattedCamelCaseData[key],
|
||||
value: keepValues[key]?.value,
|
||||
};
|
||||
});
|
||||
return formattedData;
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,19 +27,7 @@ export async function getCourseAdvancedSettings(courseId) {
|
||||
export async function updateCourseAdvancedSettings(courseId, settings) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
|
||||
const keepValues = {};
|
||||
Object.keys(data).forEach((key) => {
|
||||
keepValues[camelCase(key)] = { value: data[key].value };
|
||||
});
|
||||
const formattedData = {};
|
||||
const formattedCamelCaseData = camelCaseObject(data);
|
||||
Object.keys(formattedCamelCaseData).forEach((key) => {
|
||||
formattedData[key] = {
|
||||
...formattedCamelCaseData[key],
|
||||
value: keepValues[key]?.value,
|
||||
};
|
||||
});
|
||||
return formattedData;
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,17 +37,5 @@ export async function updateCourseAdvancedSettings(courseId, settings) {
|
||||
*/
|
||||
export async function getProctoringExamErrors(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
|
||||
const keepValues = {};
|
||||
Object.keys(data).forEach((key) => {
|
||||
keepValues[camelCase(key)] = { value: data[key].value };
|
||||
});
|
||||
const formattedData = {};
|
||||
const formattedCamelCaseData = camelCaseObject(data);
|
||||
Object.keys(formattedCamelCaseData).forEach((key) => {
|
||||
formattedData[key] = {
|
||||
...formattedCamelCaseData[key],
|
||||
value: keepValues[key]?.value,
|
||||
};
|
||||
});
|
||||
return formattedData;
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
getCourseAdvancedSettings,
|
||||
updateCourseAdvancedSettings,
|
||||
getProctoringExamErrors,
|
||||
} from './api';
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedHttpClient: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('courseSettings API', () => {
|
||||
const mockHttpClient = {
|
||||
get: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
|
||||
});
|
||||
|
||||
describe('getCourseAdvancedSettings', () => {
|
||||
it('should fetch and unformat course advanced settings', async () => {
|
||||
const fakeData = {
|
||||
key_snake_case: {
|
||||
display_name: 'To come camelCase',
|
||||
testCamelCase: 'This key must not be formatted',
|
||||
PascalCase: 'To come camelCase',
|
||||
'kebab-case': 'To come camelCase',
|
||||
UPPER_CASE: 'To come camelCase',
|
||||
lowercase: 'This key must not be formatted',
|
||||
UPPERCASE: 'To come lowercase',
|
||||
'Title Case': 'To come camelCase',
|
||||
'dot.case': 'To come camelCase',
|
||||
SCREAMING_SNAKE_CASE: 'To come camelCase',
|
||||
MixedCase: 'To come camelCase',
|
||||
'Train-Case': 'To come camelCase',
|
||||
nestedOption: {
|
||||
anotherOption: 'To come camelCase',
|
||||
},
|
||||
// value is an object with various cases
|
||||
// this contain must not be formatted to camelCase
|
||||
value: {
|
||||
snake_case: 'snake_case',
|
||||
camelCase: 'camelCase',
|
||||
PascalCase: 'PascalCase',
|
||||
'kebab-case': 'kebab-case',
|
||||
UPPER_CASE: 'UPPER_CASE',
|
||||
lowercase: 'lowercase',
|
||||
UPPERCASE: 'UPPERCASE',
|
||||
'Title Case': 'Title Case',
|
||||
'dot.case': 'dot.case',
|
||||
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
|
||||
MixedCase: 'MixedCase',
|
||||
'Train-Case': 'Train-Case',
|
||||
nestedOption: {
|
||||
anotherOption: 'nestedContent',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
keySnakeCase: {
|
||||
displayName: 'To come camelCase',
|
||||
testCamelCase: 'This key must not be formatted',
|
||||
pascalCase: 'To come camelCase',
|
||||
kebabCase: 'To come camelCase',
|
||||
upperCase: 'To come camelCase',
|
||||
lowercase: 'This key must not be formatted',
|
||||
uppercase: 'To come lowercase',
|
||||
titleCase: 'To come camelCase',
|
||||
dotCase: 'To come camelCase',
|
||||
screamingSnakeCase: 'To come camelCase',
|
||||
mixedCase: 'To come camelCase',
|
||||
trainCase: 'To come camelCase',
|
||||
nestedOption: {
|
||||
anotherOption: 'To come camelCase',
|
||||
},
|
||||
value: fakeData.key_snake_case.value,
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpClient.get.mockResolvedValue({ data: fakeData });
|
||||
|
||||
const result = await getCourseAdvancedSettings('course-v1:Test+T101+2024');
|
||||
expect(mockHttpClient.get).toHaveBeenCalledWith(
|
||||
`${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024?fetch_all=0`,
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCourseAdvancedSettings', () => {
|
||||
it('should update and unformat course advanced settings', async () => {
|
||||
const fakeData = {
|
||||
key_snake_case: {
|
||||
display_name: 'To come camelCase',
|
||||
testCamelCase: 'This key must not be formatted', // because already be camelCase
|
||||
PascalCase: 'To come camelCase',
|
||||
'kebab-case': 'To come camelCase',
|
||||
UPPER_CASE: 'To come camelCase',
|
||||
lowercase: 'This key must not be formatted', // because camelCase in lowercase not formatted
|
||||
UPPERCASE: 'To come lowercase', // because camelCase in UPPERCASE format to lowercase
|
||||
'Title Case': 'To come camelCase',
|
||||
'dot.case': 'To come camelCase',
|
||||
SCREAMING_SNAKE_CASE: 'To come camelCase',
|
||||
MixedCase: 'To come camelCase',
|
||||
'Train-Case': 'To come camelCase',
|
||||
nestedOption: {
|
||||
anotherOption: 'To come camelCase',
|
||||
},
|
||||
// value is an object with various cases
|
||||
// this contain must not be formatted to camelCase
|
||||
value: {
|
||||
snake_case: 'snake_case',
|
||||
camelCase: 'camelCase',
|
||||
PascalCase: 'PascalCase',
|
||||
'kebab-case': 'kebab-case',
|
||||
UPPER_CASE: 'UPPER_CASE',
|
||||
lowercase: 'lowercase',
|
||||
UPPERCASE: 'UPPERCASE',
|
||||
'Title Case': 'Title Case',
|
||||
'dot.case': 'dot.case',
|
||||
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
|
||||
MixedCase: 'MixedCase',
|
||||
'Train-Case': 'Train-Case',
|
||||
nestedOption: {
|
||||
anotherOption: 'nestedContent',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
keySnakeCase: {
|
||||
displayName: 'To come camelCase',
|
||||
testCamelCase: 'This key must not be formatted',
|
||||
pascalCase: 'To come camelCase',
|
||||
kebabCase: 'To come camelCase',
|
||||
upperCase: 'To come camelCase',
|
||||
lowercase: 'This key must not be formatted',
|
||||
uppercase: 'To come lowercase',
|
||||
titleCase: 'To come camelCase',
|
||||
dotCase: 'To come camelCase',
|
||||
screamingSnakeCase: 'To come camelCase',
|
||||
mixedCase: 'To come camelCase',
|
||||
trainCase: 'To come camelCase',
|
||||
nestedOption: {
|
||||
anotherOption: 'To come camelCase',
|
||||
},
|
||||
value: fakeData.key_snake_case.value,
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpClient.patch.mockResolvedValue({ data: fakeData });
|
||||
|
||||
const result = await updateCourseAdvancedSettings('course-v1:Test+T101+2024', {});
|
||||
expect(mockHttpClient.patch).toHaveBeenCalledWith(
|
||||
`${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024`,
|
||||
{},
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProctoringExamErrors', () => {
|
||||
it('should fetch proctoring errors and return unformat object', async () => {
|
||||
const fakeData = {
|
||||
key_snake_case: {
|
||||
display_name: 'To come camelCase',
|
||||
testCamelCase: 'This key must not be formatted',
|
||||
PascalCase: 'To come camelCase',
|
||||
'kebab-case': 'To come camelCase',
|
||||
UPPER_CASE: 'To come camelCase',
|
||||
lowercase: 'This key must not be formatted',
|
||||
UPPERCASE: 'To come lowercase',
|
||||
'Title Case': 'To come camelCase',
|
||||
'dot.case': 'To come camelCase',
|
||||
SCREAMING_SNAKE_CASE: 'To come camelCase',
|
||||
MixedCase: 'To come camelCase',
|
||||
'Train-Case': 'To come camelCase',
|
||||
nestedOption: {
|
||||
anotherOption: 'To come camelCase',
|
||||
},
|
||||
// value is an object with various cases
|
||||
// this contain must not be formatted to camelCase
|
||||
value: {
|
||||
snake_case: 'snake_case',
|
||||
camelCase: 'camelCase',
|
||||
PascalCase: 'PascalCase',
|
||||
'kebab-case': 'kebab-case',
|
||||
UPPER_CASE: 'UPPER_CASE',
|
||||
lowercase: 'lowercase',
|
||||
UPPERCASE: 'UPPERCASE',
|
||||
'Title Case': 'Title Case',
|
||||
'dot.case': 'dot.case',
|
||||
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
|
||||
MixedCase: 'MixedCase',
|
||||
'Train-Case': 'Train-Case',
|
||||
nestedOption: {
|
||||
anotherOption: 'nestedContent',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
keySnakeCase: {
|
||||
displayName: 'To come camelCase',
|
||||
testCamelCase: 'This key must not be formatted',
|
||||
pascalCase: 'To come camelCase',
|
||||
kebabCase: 'To come camelCase',
|
||||
upperCase: 'To come camelCase',
|
||||
lowercase: 'This key must not be formatted',
|
||||
uppercase: 'To come lowercase',
|
||||
titleCase: 'To come camelCase',
|
||||
dotCase: 'To come camelCase',
|
||||
screamingSnakeCase: 'To come camelCase',
|
||||
mixedCase: 'To come camelCase',
|
||||
trainCase: 'To come camelCase',
|
||||
nestedOption: {
|
||||
anotherOption: 'To come camelCase',
|
||||
},
|
||||
value: fakeData.key_snake_case.value,
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpClient.get.mockResolvedValue({ data: fakeData });
|
||||
|
||||
const result = await getProctoringExamErrors('course-v1:Test+T101+2024');
|
||||
expect(mockHttpClient.get).toHaveBeenCalledWith(
|
||||
`${process.env.STUDIO_BASE_URL}/api/contentstore/v1/proctoring_errors/course-v1:Test+T101+2024`,
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
62
src/advanced-settings/hooks.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { fetchCourseAppSettings, fetchProctoringExamErrors } from './data/thunks';
|
||||
import {
|
||||
getCourseAppSettings, getSavingStatus, getProctoringExamErrors, getSendRequestErrors, getLoadingStatus,
|
||||
} from './data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const useAdvancedSettings = ({
|
||||
dispatch, courseId, intl, setIsQueryPending, setShowSuccessAlert, setIsEditableState, showSaveSettingsPrompt,
|
||||
showErrorModal, setErrorFields, hasInternetConnectionError,
|
||||
}) => {
|
||||
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]);
|
||||
|
||||
return {
|
||||
advancedSettingsData,
|
||||
isLoading,
|
||||
updateSettingsButtonState,
|
||||
proctoringErrors,
|
||||
mfeProctoredExamSettingsUrl,
|
||||
loadingSettingsStatus,
|
||||
savingStatus,
|
||||
};
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as AdvancedSettings } from './AdvancedSettings';
|
||||
|
||||
@@ -1,57 +1,55 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ActionRow, AlertModal, Button } from '@openedx/paragon';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { ActionRow, AlertModal, Button } from '@edx/paragon';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ModalErrorListItem from './ModalErrorListItem';
|
||||
import messages from './messages';
|
||||
|
||||
const ModalError = ({
|
||||
isError, handleUndoChanges, showErrorModal, errorList, settingsData,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.modalErrorTitle)}
|
||||
isOpen={isError}
|
||||
variant="danger"
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => showErrorModal(!isError)}
|
||||
>
|
||||
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
|
||||
</Button>
|
||||
<Button onClick={handleUndoChanges}>
|
||||
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
intl, isError, handleUndoChanges, showErrorModal, errorList, settingsData,
|
||||
}) => (
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.modalErrorTitle)}
|
||||
isOpen={isError}
|
||||
variant="danger"
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => showErrorModal(!isError)}
|
||||
>
|
||||
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
|
||||
</Button>
|
||||
<Button onClick={handleUndoChanges}>
|
||||
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.modal.error.description"
|
||||
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.modal.error.description"
|
||||
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
|
||||
Please check the following validation feedbacks and reflect them in your course settings:"
|
||||
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
|
||||
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
|
||||
/>
|
||||
</p>
|
||||
<hr />
|
||||
<ul className="p-0">
|
||||
{errorList.map((settingName) => (
|
||||
<ModalErrorListItem
|
||||
key={settingName.key}
|
||||
settingName={settingName}
|
||||
settingsData={settingsData}
|
||||
/>
|
||||
</p>
|
||||
<hr />
|
||||
<ul className="p-0">
|
||||
{errorList.map((settingName) => (
|
||||
<ModalErrorListItem
|
||||
key={settingName.key}
|
||||
settingName={settingName}
|
||||
settingsData={settingsData}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</AlertModal>
|
||||
);
|
||||
};
|
||||
))}
|
||||
</ul>
|
||||
</AlertModal>
|
||||
);
|
||||
|
||||
ModalError.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
isError: PropTypes.bool.isRequired,
|
||||
handleUndoChanges: PropTypes.func.isRequired,
|
||||
showErrorModal: PropTypes.func.isRequired,
|
||||
@@ -62,4 +60,4 @@ ModalError.propTypes = {
|
||||
settingsData: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default ModalError;
|
||||
export default injectIntl(ModalError);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Icon } from '@openedx/paragon';
|
||||
import { Error } from '@openedx/paragon/icons';
|
||||
import { Alert, Icon } from '@edx/paragon';
|
||||
import { Error } from '@edx/paragon/icons';
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
import { transformKeysToCamelCase } from '../../utils';
|
||||
|
||||
@@ -1,29 +1,23 @@
|
||||
@import "variables";
|
||||
|
||||
.advanced-settings {
|
||||
.help-sidebar {
|
||||
margin-top: 8.75rem;
|
||||
}
|
||||
|
||||
.setting-items-policies {
|
||||
.setting-items-deprecated-setting {
|
||||
float: right;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.instructions,
|
||||
strong {
|
||||
color: $text-color-base;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-card {
|
||||
.setting-items-policies {
|
||||
.setting-items-deprecated-setting {
|
||||
float: right;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.pgn__card-header .pgn__card-header-title-md {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.instructions,
|
||||
strong {
|
||||
color: $text-color-base;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-card {
|
||||
margin-bottom: 1.75rem;
|
||||
|
||||
.pgn__card-header .pgn__card-header-title-md {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +26,7 @@
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 0 .625rem;
|
||||
z-index: var(--pgn-elevation-modal-zindex);
|
||||
z-index: $zindex-modal;
|
||||
}
|
||||
|
||||
.alert-proctoring-error {
|
||||
@@ -46,11 +40,16 @@
|
||||
|
||||
.form-control {
|
||||
min-height: 2.75rem;
|
||||
flex-grow: 1;
|
||||
width: $setting-form-control-width;
|
||||
}
|
||||
|
||||
.pgn__card-section {
|
||||
max-width: $setting-form-control-width;
|
||||
}
|
||||
|
||||
.pgn__card-header {
|
||||
padding: 0 0 0 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.pgn__card-status {
|
||||
@@ -60,19 +59,21 @@
|
||||
.pgn__card-header-content {
|
||||
margin-top: 1.438rem;
|
||||
margin-bottom: 1.438rem;
|
||||
display: flex;
|
||||
flex-direction: revert;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-sidebar-supplementary {
|
||||
.setting-sidebar-supplementary-about {
|
||||
.setting-sidebar-supplementary-about-title {
|
||||
font: normal var(--pgn-typography-font-weight-bold) 1.125rem/1.5rem var(--pgn-typography-font-family-base);
|
||||
color: var(--pgn-color-headings-base);
|
||||
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
|
||||
color: $headings-color;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.setting-sidebar-supplementary-about-descriptions {
|
||||
font: normal var(--pgn-typography-font-weight-normal) .875rem/1.5rem var(--pgn-typography-font-family-base);
|
||||
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
|
||||
color: $text-color-base;
|
||||
}
|
||||
}
|
||||
@@ -81,16 +82,16 @@
|
||||
list-style: none;
|
||||
|
||||
.setting-sidebar-supplementary-other-link {
|
||||
font: normal var(--pgn-typography-font-weight-normal) .875rem/1.5rem var(--pgn-typography-font-family-base);
|
||||
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
|
||||
line-height: 1.5rem;
|
||||
color: var(--pgn-color-info-500);
|
||||
color: $info-500;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-sidebar-supplementary-other-title {
|
||||
font: normal var(--pgn-typography-font-weight-bold) 1.125rem/1.5rem var(--pgn-typography-font-family-base);
|
||||
color: var(--pgn-color-headings-base);
|
||||
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
|
||||
color: $headings-color;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
}
|
||||
@@ -102,7 +103,7 @@
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
margin-bottom: 5px;
|
||||
color: var(--pgn-color-danger-base);
|
||||
color: $danger;
|
||||
}
|
||||
|
||||
.modal-error-item-title {
|
||||
@@ -113,12 +114,12 @@
|
||||
|
||||
.modal-popup-content {
|
||||
max-width: 200px;
|
||||
color: var(--pgn-color-white);
|
||||
background-color: var(--pgn-color-black);
|
||||
color: $white;
|
||||
background-color: $black;
|
||||
filter: drop-shadow(0 2px 4px rgba(0 0 0 / .15));
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.pgn__modal-popup__arrow::after {
|
||||
border-top-color: var(--pgn-color-black);
|
||||
border-top-color: $black;
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
$text-color-base: var(--pgn-color-gray-700);
|
||||
$text-color-base: $gray-700;
|
||||
$setting-form-control-width: 34.375rem;
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
IconButton,
|
||||
ModalPopup,
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { InfoOutline, Warning } from '@openedx/paragon/icons';
|
||||
} from '@edx/paragon';
|
||||
import { InfoOutline, Warning } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { capitalize } from 'lodash';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
|
||||
import messages from './messages';
|
||||
@@ -25,12 +25,13 @@ const SettingCard = ({
|
||||
saveSettingsPrompt,
|
||||
isEditableState,
|
||||
setIsEditableState,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { deprecated, help, displayName } = settingData;
|
||||
const initialValue = JSON.stringify(settingData.value, null, 4);
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState<HTMLButtonElement | null>(null);
|
||||
const [target, setTarget] = useState(null);
|
||||
const [newValue, setNewValue] = useState(initialValue);
|
||||
|
||||
const handleSettingChange = (e) => {
|
||||
@@ -57,9 +58,8 @@ const SettingCard = ({
|
||||
return (
|
||||
<li className="field-group course-advanced-policy-list-item">
|
||||
<Card className="flex-column setting-card">
|
||||
<Card.Body className="d-flex row m-0 align-items-center">
|
||||
<Card.Body className="d-flex">
|
||||
<Card.Header
|
||||
className="col-6"
|
||||
title={(
|
||||
<ActionRow>
|
||||
{capitalize(displayName)}
|
||||
@@ -70,7 +70,7 @@ const SettingCard = ({
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.helpButtonText)}
|
||||
variant="primary"
|
||||
className="flex-shrink-0 ml-1 mr-2"
|
||||
className=" ml-1 mr-2"
|
||||
/>
|
||||
<ModalPopup
|
||||
hasArrow
|
||||
@@ -86,11 +86,10 @@ const SettingCard = ({
|
||||
dangerouslySetInnerHTML={{ __html: help }}
|
||||
/>
|
||||
</ModalPopup>
|
||||
<ActionRow.Spacer />
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
<Card.Section className="col-6 flex-grow-1">
|
||||
<Card.Section>
|
||||
<Form.Group className="m-0">
|
||||
<Form.Control
|
||||
as={TextareaAutosize}
|
||||
@@ -114,11 +113,12 @@ const SettingCard = ({
|
||||
};
|
||||
|
||||
SettingCard.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
settingData: PropTypes.shape({
|
||||
deprecated: PropTypes.bool,
|
||||
help: PropTypes.string,
|
||||
displayName: PropTypes.string,
|
||||
value: PropTypes.oneOfType([
|
||||
value: PropTypes.PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
PropTypes.number,
|
||||
@@ -135,4 +135,4 @@ SettingCard.propTypes = {
|
||||
setIsEditableState: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SettingCard;
|
||||
export default injectIntl(SettingCard);
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
@@ -21,12 +22,14 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
<textarea
|
||||
{...props}
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
)));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en">
|
||||
<SettingCard
|
||||
intl={{}}
|
||||
isOn
|
||||
name="settingName"
|
||||
setEdited={setEdited}
|
||||
@@ -55,6 +58,7 @@ describe('<SettingCard />', () => {
|
||||
const { getByText } = render(
|
||||
<IntlProvider locale="en">
|
||||
<SettingCard
|
||||
intl={{}}
|
||||
isOn
|
||||
name="settingName"
|
||||
setEdited={setEdited}
|
||||
@@ -75,19 +79,18 @@ describe('<SettingCard />', () => {
|
||||
expect(queryByText(messages.deprecated.defaultMessage)).toBeNull();
|
||||
});
|
||||
it('calls setEdited on blur', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByLabelText } = render(<RootWrapper />);
|
||||
const inputBox = getByLabelText(/Setting Name/i);
|
||||
fireEvent.focus(inputBox);
|
||||
await user.clear(inputBox);
|
||||
await user.type(inputBox, '3, 2, 1');
|
||||
userEvent.clear(inputBox);
|
||||
userEvent.type(inputBox, '3, 2, 1');
|
||||
await waitFor(() => {
|
||||
expect(inputBox).toHaveValue('3, 2, 1');
|
||||
});
|
||||
await user.tab(); // blur off of the input.
|
||||
await waitFor(() => {
|
||||
await (async () => {
|
||||
expect(setEdited).toHaveBeenCalled();
|
||||
expect(handleBlur).toHaveBeenCalled();
|
||||
});
|
||||
fireEvent.focusOut(inputBox);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
FormattedMessage,
|
||||
injectIntl,
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { HelpSidebar } from '../../generic/help-sidebar';
|
||||
import HelpSidebar from '../../generic/help-sidebar';
|
||||
import messages from './messages';
|
||||
|
||||
const SettingsSidebar = ({ courseId, proctoredExamSettingsUrl = '' }) => (
|
||||
const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => (
|
||||
<HelpSidebar
|
||||
courseId={courseId}
|
||||
proctoredExamSettingsUrl={proctoredExamSettingsUrl}
|
||||
showOtherSettings
|
||||
>
|
||||
<h4 className="help-sidebar-about-title">
|
||||
<FormattedMessage {...messages.about} />
|
||||
{intl.formatMessage(messages.about)}
|
||||
</h4>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
<FormattedMessage {...messages.aboutDescription1} />
|
||||
{intl.formatMessage(messages.aboutDescription1)}
|
||||
</p>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
<FormattedMessage {...messages.aboutDescription2} />
|
||||
{intl.formatMessage(messages.aboutDescription2)}
|
||||
</p>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
<FormattedMessage
|
||||
@@ -31,9 +34,14 @@ const SettingsSidebar = ({ courseId, proctoredExamSettingsUrl = '' }) => (
|
||||
</HelpSidebar>
|
||||
);
|
||||
|
||||
SettingsSidebar.defaultProps = {
|
||||
proctoredExamSettingsUrl: '',
|
||||
};
|
||||
|
||||
SettingsSidebar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
proctoredExamSettingsUrl: PropTypes.string,
|
||||
};
|
||||
|
||||
export default SettingsSidebar;
|
||||
export default injectIntl(SettingsSidebar);
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
// @ts-check
|
||||
import { initializeMocks, render } from '../../testUtils';
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import SettingsSidebar from './SettingsSidebar';
|
||||
import messages from './messages';
|
||||
|
||||
const courseId = 'course-123';
|
||||
let store;
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<SettingsSidebar intl={{ formatMessage: jest.fn() }} courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<SettingsSidebar />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
it('renders about and other sidebar titles correctly', () => {
|
||||
const { getByText } = render(<SettingsSidebar courseId={courseId} />);
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText(messages.about.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.other.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
it('renders about descriptions correctly', () => {
|
||||
const { getByText } = render(<SettingsSidebar courseId={courseId} />);
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const aboutThirtyDescription = getByText('When you enter strings as policy values, ensure that you use double quotation marks (“) around the string. Do not use single quotation marks (‘).');
|
||||
expect(getByText(messages.aboutDescription1.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.aboutDescription2.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
.form-group-custom {
|
||||
.pgn__form-label {
|
||||
font: normal var(--pgn-typography-font-weight-bold) .75rem/1.25rem var(--pgn-typography-font-family-base);
|
||||
color: var(--pgn-color-gray-500);
|
||||
font: normal $font-weight-bold .75rem/1.25rem $font-family-base;
|
||||
color: $gray-500;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.pgn__form-control-description,
|
||||
.pgn__form-text {
|
||||
font: normal var(--pgn-typography-font-weight-normal) .75rem/1.25rem var(--pgn-typography-font-family-base);
|
||||
color: var(--pgn-color-gray-500);
|
||||
font: normal $font-weight-normal .75rem/1.25rem $font-family-base;
|
||||
color: $gray-500;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
|
||||
.form-group-custom_isInvalid {
|
||||
input {
|
||||
border-color: var(--pgn-color-form-feedback-invalid);
|
||||
border-color: $form-feedback-invalid-color;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-error {
|
||||
color: var(--pgn-color-form-feedback-invalid);
|
||||
color: $form-feedback-invalid-color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,40 +34,40 @@
|
||||
.datepicker-custom-control {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: var(--pgn-typography-form-input-font-size-base);
|
||||
font-weight: var(--pgn-typography-form-input-font-weight);
|
||||
line-height: var(--pgn-typography-form-input-line-height-base);
|
||||
background: var(--pgn-color-form-input-bg-base);
|
||||
border-color: var(--pgn-color-form-input-border);
|
||||
border-width: var(--pgn-size-form-input-width-border);
|
||||
box-shadow: var(--pgn-elevation-form-input-base);
|
||||
border-radius: var(--pgn-size-form-input-radius-border-base);
|
||||
color: var(--pgn-color-form-input-base);
|
||||
padding: var(--pgn-spacing-form-input-padding-y-base) var(--pgn-spacing-form-input-padding-x-base);
|
||||
height: var(--pgn-size-form-input-height-base);
|
||||
font-size: $input-font-size;
|
||||
font-weight: $input-font-weight;
|
||||
line-height: $input-line-height;
|
||||
background: $input-bg;
|
||||
border-color: $input-border-color;
|
||||
border-width: $input-border-width;
|
||||
box-shadow: $input-box-shadow;
|
||||
border-radius: $input-border-radius;
|
||||
color: $input-color;
|
||||
padding: $input-padding-y $input-padding-x;
|
||||
height: $input-height;
|
||||
resize: none;
|
||||
|
||||
&:focus,
|
||||
:focus-visible {
|
||||
color: var(--pgn-color-form-input-focus-base);
|
||||
background-color: var(--pgn-color-form-input-bg-base);
|
||||
border-color: var(--pgn-color-form-input-focus-border);
|
||||
box-shadow: var(--pgn-elevation-form-input-focus);
|
||||
color: $input-focus-color;
|
||||
background-color: $input-bg;
|
||||
border-color: $input-focus-border-color;
|
||||
box-shadow: $input-focus-box-shadow;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--pgn-color-form-input-placeholder);
|
||||
color: $input-placeholder-color;
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker-custom-control_readonly {
|
||||
border-color: transparent;
|
||||
background: var(--pgn-color-form-input-bg-disabled);
|
||||
background: $input-disabled-bg;
|
||||
}
|
||||
|
||||
.datepicker-custom-control_isInvalid {
|
||||
border-color: var(--pgn-color-form-feedback-invalid);
|
||||
border-color: $form-feedback-invalid-color;
|
||||
}
|
||||
|
||||
.datepicker-custom-control-icon {
|
||||
@@ -76,10 +76,6 @@
|
||||
right: 1.188rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--pgn-color-black);
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
|
||||
.react-datepicker-popper {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||