Compare commits
164 Commits
open-relea
...
refactor--
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c044186dba | ||
|
|
e50b8c7407 | ||
|
|
ffae3bd868 | ||
|
|
181f9c7a5f | ||
|
|
1d95af5a31 | ||
|
|
d7a4b5b45b | ||
|
|
2e8eed7504 | ||
|
|
d768bfc97a | ||
|
|
9c997ab845 | ||
|
|
c1976ce4d3 | ||
|
|
be74de2b22 | ||
|
|
fda1208660 | ||
|
|
b65f4f2b74 | ||
|
|
530c355787 | ||
|
|
fc21e22afb | ||
|
|
f9bc5c4927 | ||
|
|
484b141328 | ||
|
|
dc0762312e | ||
|
|
33f46be993 | ||
|
|
d1c176cfc8 | ||
|
|
17d14968fa | ||
|
|
df51130fce | ||
|
|
bc05d2c01e | ||
|
|
a0e37c0357 | ||
|
|
a218e7e5f8 | ||
|
|
f2a4386892 | ||
|
|
c9b111a022 | ||
|
|
b9feb50a2c | ||
|
|
7fdf8da8ed | ||
|
|
1dba6208a5 | ||
|
|
9f4422d1b9 | ||
|
|
8bfc3f2945 | ||
|
|
0e1a7e2603 | ||
|
|
cc7fc6a9e1 | ||
|
|
da1e7a0277 | ||
|
|
87ead24e20 | ||
|
|
e05e6325c9 | ||
|
|
b090c8c153 | ||
|
|
3c3dfeb325 | ||
|
|
7ee8cc7fb1 | ||
|
|
912fff9b0f | ||
|
|
2c71385ce7 | ||
|
|
139457087b | ||
|
|
3a26285bd1 | ||
|
|
e2c1deaeb3 | ||
|
|
61baf1a886 | ||
|
|
51e5e7126c | ||
|
|
a53a93ccee | ||
|
|
e980f1f20e | ||
|
|
fac9eab496 | ||
|
|
1b1afcf195 | ||
|
|
788f671626 | ||
|
|
ac7b4c9fcf | ||
|
|
9a4af8ff2e | ||
|
|
9cfd8013d2 | ||
|
|
74f5a0e8ee | ||
|
|
0d67c2588d | ||
|
|
738f501cf9 | ||
|
|
ff6a5d99d6 | ||
|
|
a46a34412c | ||
|
|
db6c3172de | ||
|
|
0d38279950 | ||
|
|
3dd28082ea | ||
|
|
767283cbc6 | ||
|
|
0066902127 | ||
|
|
9a567b875e | ||
|
|
a7f877caf5 | ||
|
|
e75928a774 | ||
|
|
4b7f46852b | ||
|
|
1e0c128ad6 | ||
|
|
e3887129fc | ||
|
|
2eaf882734 | ||
|
|
284c402a49 | ||
|
|
d08eb0e3a9 | ||
|
|
76b7623cb0 | ||
|
|
1e25091698 | ||
|
|
1289f7d4e2 | ||
|
|
eb1b2eb883 | ||
|
|
74e45139bf | ||
|
|
f9a240ade4 | ||
|
|
b09e7f3683 | ||
|
|
b19d52555f | ||
|
|
ab4dd9a4a8 | ||
|
|
a94942a36e | ||
|
|
ab7c51994c | ||
|
|
67967a92cf | ||
|
|
6efa8c5356 | ||
|
|
c28669f5b2 | ||
|
|
270f4a8a12 | ||
|
|
641a169e6f | ||
|
|
25e254bbfb | ||
|
|
af0ddf532a | ||
|
|
eaf76c8dee | ||
|
|
5c0ca7b706 | ||
|
|
530b247c33 | ||
|
|
a5bc86e948 | ||
|
|
9910937269 | ||
|
|
1344c289df | ||
|
|
7f4111c12c | ||
|
|
105fdea8ef | ||
|
|
9d91e3f242 | ||
|
|
fdcb3a5e7f | ||
|
|
86974b76a9 | ||
|
|
835915750c | ||
|
|
fe8a125d1a | ||
|
|
f82e572ad2 | ||
|
|
8aa03496fb | ||
|
|
3c2c347bb9 | ||
|
|
0d166288cc | ||
|
|
f8954ef870 | ||
|
|
66afd4ddac | ||
|
|
a99eb8a44a | ||
|
|
b2981318b0 | ||
|
|
5142f3afd4 | ||
|
|
b7b3601337 | ||
|
|
50e5ca86c6 | ||
|
|
fe9a9a37e7 | ||
|
|
1c5ab42ea6 | ||
|
|
74fcbe426d | ||
|
|
6a65826fd5 | ||
|
|
ad47bfacd4 | ||
|
|
9e9bac997b | ||
|
|
014fbeac71 | ||
|
|
0b214faeca | ||
|
|
abff65a11a | ||
|
|
2f6eed237a | ||
|
|
7253c9bba3 | ||
|
|
a84d3c09e8 | ||
|
|
f5e1f1cf6b | ||
|
|
8096a389da | ||
|
|
1a21850fc4 | ||
|
|
efae5ecd4b | ||
|
|
1e043325d6 | ||
|
|
0bfce5594d | ||
|
|
dbe2787785 | ||
|
|
20e98319af | ||
|
|
2063049747 | ||
|
|
a7def9ce25 | ||
|
|
2418207149 | ||
|
|
5da5967e97 | ||
|
|
45b2bf5b13 | ||
|
|
7527f6c764 | ||
|
|
bdfa1fdeb3 | ||
|
|
79f58cc8d0 | ||
|
|
437d0a37a9 | ||
|
|
0e24a0767b | ||
|
|
91abf56977 | ||
|
|
f0734d86db | ||
|
|
3581d633c1 | ||
|
|
b8895bef33 | ||
|
|
89d0d12559 | ||
|
|
34fe291268 | ||
|
|
4b1e292e1c | ||
|
|
90eb6fd0c3 | ||
|
|
50da8a0f0b | ||
|
|
7dcd328f2e | ||
|
|
e2d66cc605 | ||
|
|
f5fc721b3b | ||
|
|
c4bbb6fa70 | ||
|
|
748aee2cff | ||
|
|
31473d3f49 | ||
|
|
4de727791a | ||
|
|
d1d04d5585 | ||
|
|
6f41a14012 |
18
.env
18
.env
@@ -16,6 +16,8 @@ LOGO_URL=''
|
||||
LOGO_WHITE_URL=''
|
||||
LOGOUT_URL=null
|
||||
MARKETING_SITE_BASE_URL=''
|
||||
TERMS_OF_SERVICE_URL=''
|
||||
PRIVACY_POLICY_URL=''
|
||||
ORDER_HISTORY_URL=''
|
||||
PUBLISHER_BASE_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
@@ -24,7 +26,21 @@ SITE_NAME=''
|
||||
SUPPORT_EMAIL=''
|
||||
SUPPORT_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
ENABLE_ACCESSIBILITY_PAGE=false
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
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=''
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
NODE_ENV='development'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:2001'
|
||||
BASE_URL='http://localhost:2001'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL=
|
||||
@@ -16,17 +16,33 @@ LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
TERMS_OF_SERVICE_URL=
|
||||
PRIVACY_POLICY_URL=
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
PORT=2001
|
||||
PUBLISHER_BASE_URL=
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
SITE_NAME='Your Plaform Name Here'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
SUPPORT_EMAIL='support@example.com'
|
||||
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_NEW_EDITOR_PAGES=true
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
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"
|
||||
|
||||
14
.env.test
14
.env.test
@@ -1,5 +1,5 @@
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:2001'
|
||||
BASE_URL='http://localhost:2001'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
@@ -28,4 +28,14 @@ USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=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"
|
||||
|
||||
10
.eslintrc.js
10
.eslintrc.js
@@ -1,6 +1,8 @@
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('eslint',
|
||||
module.exports = createConfig(
|
||||
'eslint',
|
||||
{
|
||||
rules: {
|
||||
'jsx-a11y/label-has-associated-control': [2, {
|
||||
@@ -8,6 +10,8 @@ module.exports = createConfig('eslint',
|
||||
}],
|
||||
'template-curly-spacing': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
indent: 'off',
|
||||
indent: ['error', 2],
|
||||
'no-restricted-exports': 'off',
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -16,4 +16,4 @@ jobs:
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
|
||||
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "label: " it tries to apply
|
||||
# the label indicated in rest of comment.
|
||||
# If the comment starts with "remove label: ", it tries
|
||||
# to remove the indicated label.
|
||||
# Note: Labels are allowed to have spaces and this script does
|
||||
# not parse spaces (as often a space is legitimate), so the command
|
||||
# "label: really long lots of words label" will apply the
|
||||
# label "really long lots of words label"
|
||||
|
||||
name: Allows for the adding and removing of labels via comment
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
add_remove_labels:
|
||||
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
|
||||
|
||||
2
.github/workflows/lockfileversion-check.yml
vendored
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
|
||||
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "assign me" it assigns the author to the
|
||||
# ticket (case insensitive)
|
||||
|
||||
name: Assign comment author to ticket if they say "assign me"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
self_assign_by_comment:
|
||||
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master
|
||||
12
.github/workflows/update-browserslist-db.yml
vendored
Normal file
12
.github/workflows/update-browserslist-db.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
name: Update Browserslist DB
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-browserslist:
|
||||
uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
|
||||
|
||||
secrets:
|
||||
requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}
|
||||
11
.github/workflows/validate.yml
vendored
11
.github/workflows/validate.yml
vendored
@@ -9,14 +9,13 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
|
||||
34
.stylelintrc.json
Normal file
34
.stylelintrc.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"extends": ["@edx/stylelint-config-edx"],
|
||||
"rules": {
|
||||
"selector-pseudo-class-no-unknown": [true, {
|
||||
"ignorePseudoClasses": ["export"]
|
||||
}],
|
||||
"unit-no-unknown": [true, {
|
||||
"ignoreUnits": ["\\.5"]
|
||||
}],
|
||||
"property-no-vendor-prefix": [true, {
|
||||
"ignoreProperties": ["animation", "filter"]
|
||||
}],
|
||||
"value-no-vendor-prefix": [true, {
|
||||
"ignoreValues": ["fill-available"]
|
||||
}],
|
||||
"function-no-unknown": null,
|
||||
"number-leading-zero": "never",
|
||||
"no-descending-specificity": null,
|
||||
"selector-class-pattern": null,
|
||||
"scss/no-global-function-names": null,
|
||||
"color-hex-case": "upper",
|
||||
"color-hex-length": "long",
|
||||
"scss/dollar-variable-empty-line-before": null,
|
||||
"scss/dollar-variable-colon-space-after": "at-least-one-space",
|
||||
"at-rule-no-unknown": null,
|
||||
"scss/at-rule-no-unknown": true,
|
||||
"scss/at-import-partial-extension": null,
|
||||
"scss/comment-no-empty": null,
|
||||
"property-no-unknown": [true, {
|
||||
"ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"]
|
||||
}],
|
||||
"alpha-value-notation": "number"
|
||||
}
|
||||
}
|
||||
21
Makefile
Executable file → Normal file
21
Makefile
Executable file → Normal file
@@ -1,12 +1,11 @@
|
||||
transifex_resource = frontend-app-course-authoring
|
||||
export TRANSIFEX_RESOURCE = ${transifex_resource}
|
||||
transifex_langs = "ar,fr,es_419,zh_CN"
|
||||
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
|
||||
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
|
||||
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
|
||||
|
||||
# This directory must match .babelrc .
|
||||
transifex_temp = ./temp/babel-plugin-react-intl
|
||||
@@ -45,9 +44,23 @@ push_translations:
|
||||
# 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 -f --mode reviewed --languages=$(transifex_langs)
|
||||
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 --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) paragon frontend-component-footer frontend-app-course-authoring
|
||||
endif
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
|
||||
292
README.rst
292
README.rst
@@ -1,39 +1,223 @@
|
||||
|Build Status| |Codecov| |license|
|
||||
|
||||
frontend-app-course-authoring
|
||||
=============================
|
||||
#############################
|
||||
|
||||
Please tag `@edx/teaching-and-learning <https://github.com/orgs/edx/teams/teaching-and-learning>`_ on any PRs or issues. Thanks.
|
||||
|license-badge| |status-badge| |codecov-badge|
|
||||
|
||||
Prerequisite
|
||||
------------
|
||||
|
||||
`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.
|
||||
Purpose
|
||||
*******
|
||||
|
||||
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
|
||||
|
||||
Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below.
|
||||
|
||||
|
||||
************
|
||||
Getting Started
|
||||
************
|
||||
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
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 Startup
|
||||
===================
|
||||
|
||||
Installation and Startup
|
||||
------------------------
|
||||
|
||||
1. Clone the repo:
|
||||
|
||||
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
|
||||
|
||||
2. Install npm dependencies:
|
||||
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``
|
||||
|
||||
3. Start the dev server:
|
||||
|
||||
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
|
||||
********
|
||||
|
||||
Feature: Pages and Resources Studio Tab
|
||||
=======================================
|
||||
|
||||
Enables a "Pages & Resources" menu item in Studio, under the "Content" menu.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
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:
|
||||
|
||||
* ``discussions.pages_and_resources_mfe``: must be enabled for the set of users meant to access this feature.
|
||||
|
||||
* `frontend-app-learning <https://github.com/openedx/frontend-app-learning>`_: This MFE expects it to be the LMS frontend.
|
||||
* `frontend-app-discussions <https://github.com/openedx/frontend-app-discussions/>`_: This is what the "Discussions" configuration provided by this feature actually configures. Without it, discussion settings are ignored.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
In additional to the standard settings, the following local configuration items are required:
|
||||
|
||||
* ``LEARNING_BASE_URL``: points to Learning MFE; necessary so that the `View Live` button works
|
||||
* ``ENABLE_PROGRESS_GRAPH_SETTINGS``: allow enabling or disabling the learner progress graph course-wide
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
Clicking on the "Pages & Resources" menu item takes the user to the course's ``pages-and-resources`` standalone page in this MFE. (In a devstack, for instance: http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources.)
|
||||
|
||||
UX-wise, **Pages & Resources** is meant to look like a Studio tab, so reproduces Studio's header.
|
||||
|
||||
For a particular course, this page allows one to:
|
||||
|
||||
* Configure the new Discussions MFE (making this a requirement for it). This includes:
|
||||
|
||||
* Enabling/disabling the feature entirely
|
||||
* Picking a different discussion provider, while showing a comparison matrix between them:
|
||||
|
||||
* edX
|
||||
* Ed Discussion
|
||||
* InScribe
|
||||
* Piazza
|
||||
* Yellowdig
|
||||
|
||||
* Allowing to configure the selected provider
|
||||
|
||||
* Enable/Disable learner progress
|
||||
* Enable/Disable learner notes
|
||||
* Enable/Disable the learner wiki
|
||||
* Enable/Disable the LMS calculator
|
||||
* Go to the textbook management page in Studio (in a devstack: http://localhost:18010/textbooks/course-v1:edX+DemoX+Demo_Course)
|
||||
* Go to the custom page management page in Studio(in a devstack http://localhost:18010/tabs/course-v1:edX+DemoX+Demo_Course)
|
||||
|
||||
Feature: New React XBlock Editors
|
||||
=================================
|
||||
|
||||
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
|
||||
-------------
|
||||
|
||||
In additional to the standard settings, the following local configuration item is required:
|
||||
|
||||
* ``EXAMS_BASE_URL``: URL to the ``edx-exams`` deployment
|
||||
|
||||
Feature Description
|
||||
-------------------
|
||||
|
||||
In Studio, a new item ("Proctored Exam Settings") is added to "Other Course Settings" in the course's "Certificates" settings page. When clicked, this takes the author to the corresponding page in the Course Authoring MFE, where one can:
|
||||
|
||||
* Enable proctored exams for the course
|
||||
* Allow opting out of proctored exams
|
||||
* Select a proctoring provider
|
||||
* Enable automatic creation of Zendesk tickets for "suspicious" proctored exam attempts
|
||||
|
||||
|
||||
**********
|
||||
Developing
|
||||
**********
|
||||
|
||||
`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:
|
||||
|
||||
- `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
|
||||
========================
|
||||
|
||||
* ``npm ERR! gyp ERR! build error`` while running npm install on Macs with M1 processors: Probably due to a compatibility issue of node-canvas with M1.
|
||||
|
||||
Run ``brew install pkg-config pixman cairo pango libpng jpeg giflib librsvg`` before ``npm install`` to get the correct versions of the dependencies.
|
||||
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
|
||||
*********
|
||||
|
||||
Production Build
|
||||
----------------
|
||||
================
|
||||
|
||||
The production build is created with ``npm run build``.
|
||||
|
||||
@@ -43,3 +227,87 @@ The production build is created with ``npm run build``.
|
||||
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
|
||||
:target: @edx/frontend-app-course-authoring
|
||||
|
||||
Internationalization
|
||||
====================
|
||||
|
||||
Please see refer to the `frontend-platform i18n howto`_ for documentation on
|
||||
internationalization.
|
||||
|
||||
.. _frontend-platform i18n howto: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
|
||||
|
||||
Getting Help
|
||||
************
|
||||
|
||||
If you're having trouble, we have discussion forums at
|
||||
https://discuss.openedx.org where you can connect with others in the community.
|
||||
|
||||
Our real-time conversations are on Slack. You can request a `Slack
|
||||
invitation`_, then join our `community Slack workspace`_. Because this is a
|
||||
frontend repository, the best place to discuss it would be in the `#wg-frontend
|
||||
channel`_.
|
||||
|
||||
For anything non-trivial, the best path is to open an issue in this repository
|
||||
with as many details about the issue you are facing as you can provide.
|
||||
|
||||
https://github.com/openedx/frontend-app-course-authoring/issues
|
||||
|
||||
For more information about these options, see the `Getting Help`_ page.
|
||||
|
||||
.. _Slack invitation: https://openedx.org/slack
|
||||
.. _community Slack workspace: https://openedx.slack.com/
|
||||
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
|
||||
.. _Getting Help: https://openedx.org/community/connect
|
||||
|
||||
License
|
||||
*******
|
||||
|
||||
The code in this repository is licensed under the AGPLv3 unless otherwise
|
||||
noted.
|
||||
|
||||
Please see `LICENSE <LICENSE>`_ for details.
|
||||
|
||||
Contributing
|
||||
************
|
||||
|
||||
Contributions are very welcome. Please read `How To Contribute`_ for details.
|
||||
|
||||
.. _How To Contribute: https://openedx.org/r/how-to-contribute
|
||||
|
||||
This project is currently accepting all types of contributions, bug fixes,
|
||||
security fixes, maintenance work, or new features. However, please make sure
|
||||
to have a discussion about your new feature idea with the maintainers prior to
|
||||
beginning development to maximize the chances of your change being accepted.
|
||||
You can start a conversation by creating a new issue on this repo summarizing
|
||||
your idea.
|
||||
|
||||
The Open edX Code of Conduct
|
||||
****************************
|
||||
|
||||
All community members are expected to follow the `Open edX Code of Conduct`_.
|
||||
|
||||
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
|
||||
|
||||
People
|
||||
******
|
||||
|
||||
The assigned maintainers for this component and other project details may be
|
||||
found in `Backstage`_. Backstage pulls this data from the ``catalog-info.yaml``
|
||||
file in this repo.
|
||||
|
||||
.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/frontend-app-course-authoring
|
||||
|
||||
Reporting Security Issues
|
||||
*************************
|
||||
|
||||
Please do not report security issues in public, and email security@openedx.org instead.
|
||||
|
||||
.. |license-badge| image:: https://img.shields.io/github/license/openedx/frontend-app-course-authoring.svg
|
||||
:target: https://github.com/openedx/frontend-app-course-authoring/blob/master/LICENSE
|
||||
:alt: License
|
||||
|
||||
.. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen
|
||||
|
||||
.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-course-authoring/coverage.svg?branch=master
|
||||
:target: https://codecov.io/github/openedx/frontend-app-course-authoring?branch=master
|
||||
:alt: Codecov
|
||||
33613
package-lock.json
generated
33613
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -12,8 +12,9 @@
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"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",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
@@ -34,11 +35,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-build": "^11.0.0",
|
||||
"@edx/frontend-component-footer": "11.1.1",
|
||||
"@edx/frontend-lib-content-components": "^1.43.0",
|
||||
"@edx/frontend-platform": "2.5.1",
|
||||
"@edx/paragon": "20.6.1",
|
||||
"@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",
|
||||
@@ -49,16 +50,20 @@
|
||||
"core-js": "3.8.1",
|
||||
"email-validator": "2.0.4",
|
||||
"formik": "2.2.6",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"moment": "2.29.2",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.14.0",
|
||||
"react-datepicker": "^4.13.0",
|
||||
"react-dom": "16.14.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-ranger": "^2.1.0",
|
||||
"react-redux": "7.1.3",
|
||||
"react-responsive": "8.1.0",
|
||||
"react-router": "5.1.2",
|
||||
"react-router-dom": "5.1.2",
|
||||
"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",
|
||||
@@ -67,8 +72,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.0.0",
|
||||
"@edx/frontend-build": "^11.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",
|
||||
|
||||
@@ -1,20 +1,58 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import { 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 PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
||||
import { getCourseAppsApiStatus, getLoadingStatus } from './pages-and-resources/data/selectors';
|
||||
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
|
||||
import { RequestStatus } from './data/constants';
|
||||
import Loading from './generic/Loading';
|
||||
|
||||
export default function CourseAuthoringPage({ courseId, children }) {
|
||||
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();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,41 +65,36 @@ export default function CourseAuthoringPage({ courseId, children }) {
|
||||
const courseOrg = courseDetail ? courseDetail.org : null;
|
||||
const courseTitle = courseDetail ? courseDetail.name : courseId;
|
||||
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
|
||||
const inProgress = useSelector(getLoadingStatus) === RequestStatus.IN_PROGRESS;
|
||||
const inProgress = useSelector(state => state.courseDetail.status) === RequestStatus.IN_PROGRESS;
|
||||
const { pathname } = useLocation();
|
||||
const showHeader = !pathname.includes('/editor');
|
||||
|
||||
if (courseAppsApiStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<PermissionDeniedAlert />
|
||||
);
|
||||
}
|
||||
|
||||
const AppHeader = () => (
|
||||
<Header
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
);
|
||||
|
||||
const AppFooter = () => (
|
||||
<div className="mt-6">
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-light-200">
|
||||
{/* While V2 Editors are tempoarily served from thier own pages
|
||||
<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 ? !pathname.includes('/editor/') && <Loading /> : <AppHeader />}
|
||||
{inProgress ? showHeader && <Loading />
|
||||
: (showHeader && (
|
||||
<AppHeader
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{children}
|
||||
{!inProgress && <AppFooter />}
|
||||
{!inProgress && showHeader && <AppFooter />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
CourseAuthoringPage.propTypes = {
|
||||
children: PropTypes.node,
|
||||
@@ -71,3 +104,5 @@ CourseAuthoringPage.propTypes = {
|
||||
CourseAuthoringPage.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
export default CourseAuthoringPage;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { queryByTestId, render } from '@testing-library/react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
@@ -23,51 +23,6 @@ jest.mock('react-router-dom', () => ({
|
||||
}));
|
||||
let axiosMock;
|
||||
let store;
|
||||
let container;
|
||||
function renderComponent() {
|
||||
const wrapper = render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</CourseAuthoringPage>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
,
|
||||
);
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
const mockStore = async () => {
|
||||
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const courseAppsApiUrl = `${apiBaseUrl}/api/course_apps/v1/apps`;
|
||||
axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(403, {
|
||||
response: { status: 403 },
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
};
|
||||
describe('DiscussionsSettings', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
test('renders permission error in case of 403', async () => {
|
||||
await mockStore();
|
||||
renderComponent();
|
||||
expect(queryByTestId(container, 'permissionDeniedAlert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor Pages Load no header', () => {
|
||||
const mockStoreSuccess = async () => {
|
||||
|
||||
@@ -2,10 +2,19 @@ import React from 'react';
|
||||
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 FilesAndUploads from './files-and-uploads';
|
||||
import { AdvancedSettings } from './advanced-settings';
|
||||
import ScheduleAndDetails from './schedule-and-details';
|
||||
import { GradingSettings } from './grading-settings';
|
||||
import CourseTeam from './course-team/CourseTeam';
|
||||
import { CourseUpdates } from './course-updates';
|
||||
|
||||
/**
|
||||
* As of this writing, these routes are mounted at a path prefixed with the following:
|
||||
@@ -23,30 +32,91 @@ import EditorContainer from './editors/EditorContainer';
|
||||
* 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.
|
||||
*/
|
||||
export default function CourseAuthoringRoutes({ courseId }) {
|
||||
const CourseAuthoringRoutes = ({ courseId }) => {
|
||||
const { path } = useRouteMatch();
|
||||
return (
|
||||
<CourseAuthoringPage courseId={courseId}>
|
||||
<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}/editor/:blockType/:blockId`}>
|
||||
<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'
|
||||
&& (
|
||||
<EditorContainer
|
||||
courseId={courseId}
|
||||
/>
|
||||
<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;
|
||||
|
||||
143
src/CourseAuthoringRoutes.test.jsx
Normal file
143
src/CourseAuthoringRoutes.test.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
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 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();
|
||||
|
||||
// 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,
|
||||
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
|
||||
})),
|
||||
}));
|
||||
|
||||
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;
|
||||
});
|
||||
jest.mock('./selectors/VideoSelectorContainer', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return videoSelectorContainerMockText;
|
||||
});
|
||||
jest.mock('./custom-pages/CustomPages', () => (props) => {
|
||||
mockComponentFn(props);
|
||||
return customPagesMockText;
|
||||
});
|
||||
|
||||
describe('<CourseAuthoringRoutes>', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
// 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(
|
||||
<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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// 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(
|
||||
<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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the EditorContainer component when the course editor route is active', () => {
|
||||
render(
|
||||
<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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
265
src/advanced-settings/AdvancedSettings.jsx
Normal file
265
src/advanced-settings/AdvancedSettings.jsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Container, Button, Layout, StatefulButton, TransitionReplace,
|
||||
} 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 InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import { parseArrayOrObjectValues } from '../utils';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
import { fetchCourseAppSettings, updateCourseAppSetting, fetchProctoringExamErrors } from './data/thunks';
|
||||
import {
|
||||
getCourseAppSettings, getSavingStatus, getProctoringExamErrors, getSendRequestErrors, getLoadingStatus,
|
||||
} from './data/selectors';
|
||||
import SettingCard from './setting-card/SettingCard';
|
||||
import SettingsSidebar from './settings-sidebar/SettingsSidebar';
|
||||
import validateAdvancedSettingsData from './utils';
|
||||
import messages from './messages';
|
||||
import ModalError from './modal-error/ModalError';
|
||||
import { useAdvancedSettings } from './hooks';
|
||||
|
||||
const AdvancedSettings = ({ intl, courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
|
||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||
const [errorModal, showErrorModal] = useState(false);
|
||||
const [editedSettings, setEditedSettings] = useState({});
|
||||
const [errorFields, setErrorFields] = useState([]);
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
|
||||
const [isQueryPending, setIsQueryPending] = useState(false);
|
||||
const [isEditableState, setIsEditableState] = useState(false);
|
||||
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
|
||||
|
||||
const {
|
||||
advancedSettingsData,
|
||||
isLoading,
|
||||
updateSettingsButtonState,
|
||||
proctoringErrors,
|
||||
mfeProctoredExamSettingsUrl,
|
||||
loadingSettingsStatus,
|
||||
savingStatus,
|
||||
} = useAdvancedSettings({
|
||||
dispatch,
|
||||
courseId,
|
||||
intl,
|
||||
setIsQueryPending,
|
||||
setShowSuccessAlert,
|
||||
setIsEditableState,
|
||||
showSaveSettingsPrompt,
|
||||
showErrorModal,
|
||||
setErrorFields,
|
||||
hasInternetConnectionError,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
}
|
||||
if (loadingSettingsStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<div className="row justify-contnt-center m-6">
|
||||
<Placeholder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleResetSettingsValues = () => {
|
||||
setIsEditableState(false);
|
||||
showErrorModal(false);
|
||||
setEditedSettings({});
|
||||
showSaveSettingsPrompt(false);
|
||||
};
|
||||
|
||||
const handleSettingBlur = () => {
|
||||
validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
|
||||
};
|
||||
|
||||
const handleUpdateAdvancedSettingsData = () => {
|
||||
const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
|
||||
if (isValid) {
|
||||
setIsQueryPending(true);
|
||||
} else {
|
||||
showSaveSettingsPrompt(false);
|
||||
showErrorModal(!errorModal);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInternetConnectionFailed = () => {
|
||||
setInternetConnectionError(true);
|
||||
showSaveSettingsPrompt(false);
|
||||
setShowSuccessAlert(false);
|
||||
};
|
||||
|
||||
const handleQueryProcessing = () => {
|
||||
setShowSuccessAlert(false);
|
||||
dispatch(updateCourseAppSetting(courseId, parseArrayOrObjectValues(editedSettings)));
|
||||
};
|
||||
|
||||
const handleManuallyChangeClick = (setToState) => {
|
||||
showErrorModal(setToState);
|
||||
showSaveSettingsPrompt(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container size="xl" className="px-4">
|
||||
<div className="setting-header mt-5">
|
||||
{(proctoringErrors?.length > 0) && (
|
||||
<AlertProctoringError
|
||||
icon={Info}
|
||||
proctoringErrorsData={proctoringErrors}
|
||||
aria-hidden="true"
|
||||
aria-labelledby={intl.formatMessage(messages.alertProctoringAriaLabelledby)}
|
||||
aria-describedby={intl.formatMessage(messages.alertProctoringDescribedby)}
|
||||
/>
|
||||
)}
|
||||
<TransitionReplace>
|
||||
{showSuccessAlert ? (
|
||||
<AlertMessage
|
||||
key={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
|
||||
show={showSuccessAlert}
|
||||
variant="success"
|
||||
icon={CheckCircle}
|
||||
title={intl.formatMessage(messages.alertSuccess)}
|
||||
description={intl.formatMessage(messages.alertSuccessDescriptions)}
|
||||
aria-hidden="true"
|
||||
aria-labelledby={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
|
||||
aria-describedby={intl.formatMessage(messages.alertSuccessAriaDescribedby)}
|
||||
/>
|
||||
) : null}
|
||||
</TransitionReplace>
|
||||
</div>
|
||||
<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 }]}
|
||||
md={[{ span: 9 }, { span: 3 }]}
|
||||
sm={[{ span: 9 }, { span: 3 }]}
|
||||
xs={[{ span: 9 }, { span: 3 }]}
|
||||
xl={[{ span: 9 }, { span: 3 }]}
|
||||
>
|
||||
<Layout.Element>
|
||||
<article>
|
||||
<div>
|
||||
<section className="setting-items-policies">
|
||||
<div className="small">
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.policies.description"
|
||||
defaultMessage="{notice} Do not modify these policies unless you are familiar with their purpose."
|
||||
values={{ notice: <strong>Warning: </strong> }}
|
||||
/>
|
||||
</div>
|
||||
<div className="setting-items-deprecated-setting">
|
||||
<Button
|
||||
variant={showDeprecated ? 'outline-brand' : 'tertiary'}
|
||||
onClick={() => setShowDeprecated(!showDeprecated)}
|
||||
size="sm"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.deprecated.button.text"
|
||||
defaultMessage="{visibility} deprecated settings"
|
||||
values={{
|
||||
visibility:
|
||||
showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText)
|
||||
: intl.formatMessage(messages.deprecatedButtonShowText),
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="setting-items-list p-0">
|
||||
{Object.keys(advancedSettingsData).map((settingName) => {
|
||||
const settingData = advancedSettingsData[settingName];
|
||||
if (settingData.deprecated && !showDeprecated) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SettingCard
|
||||
key={settingName}
|
||||
settingData={settingData}
|
||||
name={settingName}
|
||||
showSaveSettingsPrompt={showSaveSettingsPrompt}
|
||||
saveSettingsPrompt={saveSettingsPrompt}
|
||||
setEdited={setEditedSettings}
|
||||
handleBlur={handleSettingBlur}
|
||||
isEditableState={isEditableState}
|
||||
setIsEditableState={setIsEditableState}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</Layout.Element>
|
||||
<Layout.Element>
|
||||
<SettingsSidebar
|
||||
courseId={courseId}
|
||||
proctoredExamSettingsUrl={mfeProctoredExamSettingsUrl}
|
||||
/>
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
</section>
|
||||
</Container>
|
||||
<div className="alert-toast">
|
||||
{isQueryPending && (
|
||||
<InternetConnectionAlert
|
||||
isFailed={savingStatus === RequestStatus.FAILED}
|
||||
isQueryPending={isQueryPending}
|
||||
onQueryProcessing={handleQueryProcessing}
|
||||
onInternetConnectionFailed={handleInternetConnectionFailed}
|
||||
/>
|
||||
)}
|
||||
<AlertMessage
|
||||
show={saveSettingsPrompt}
|
||||
aria-hidden={saveSettingsPrompt}
|
||||
aria-labelledby={intl.formatMessage(messages.alertWarningAriaLabelledby)}
|
||||
aria-describedby={intl.formatMessage(messages.alertWarningAriaDescribedby)}
|
||||
role="dialog"
|
||||
actions={[
|
||||
!isQueryPending && (
|
||||
<Button variant="tertiary" onClick={handleResetSettingsValues}>
|
||||
{intl.formatMessage(messages.buttonCancelText)}
|
||||
</Button>
|
||||
),
|
||||
<StatefulButton
|
||||
key="statefulBtn"
|
||||
onClick={handleUpdateAdvancedSettingsData}
|
||||
state={isQueryPending ? RequestStatus.PENDING : 'default'}
|
||||
{...updateSettingsButtonState}
|
||||
/>,
|
||||
].filter(Boolean)}
|
||||
variant="warning"
|
||||
icon={Warning}
|
||||
title={intl.formatMessage(messages.alertWarning)}
|
||||
description={intl.formatMessage(messages.alertWarningDescriptions)}
|
||||
/>
|
||||
</div>
|
||||
<ModalError
|
||||
isError={errorModal}
|
||||
showErrorModal={(setToState) => handleManuallyChangeClick(setToState)}
|
||||
handleUndoChanges={handleResetSettingsValues}
|
||||
settingsData={advancedSettingsData}
|
||||
errorList={errorFields.length > 0 ? errorFields : []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AdvancedSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AdvancedSettings);
|
||||
164
src/advanced-settings/AdvancedSettings.test.jsx
Normal file
164
src/advanced-settings/AdvancedSettings.test.jsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React from 'react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import initializeStore from '../store';
|
||||
import { executeThunk } from '../utils';
|
||||
import { advancedSettingsMock } from './__mocks__';
|
||||
import { getCourseAdvancedSettingsApiUrl } from './data/api';
|
||||
import { updateCourseAppSetting } from './data/thunks';
|
||||
import AdvancedSettings from './AdvancedSettings';
|
||||
import messages from './messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
|
||||
// Mock the TextareaAutosize component
|
||||
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
<textarea
|
||||
{...props}
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
)));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
pathname: mockPathname,
|
||||
}),
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<AdvancedSettings intl={injectIntl} courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<AdvancedSettings />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
|
||||
.reply(200, advancedSettingsMock);
|
||||
});
|
||||
it('should render without errors', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
|
||||
selector: 'h2.sub-header-title',
|
||||
});
|
||||
expect(advancedSettingsElement).toBeInTheDocument();
|
||||
expect(getByText(messages.policy.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should render setting element', async () => {
|
||||
const { getByText, queryByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const advancedModuleListTitle = getByText(/Advanced Module List/i);
|
||||
expect(advancedModuleListTitle).toBeInTheDocument();
|
||||
expect(queryByText('Certificate web/html view enabled')).toBeNull();
|
||||
});
|
||||
});
|
||||
it('should change to onСhange', async () => {
|
||||
const { getByLabelText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const textarea = getByLabelText(/Advanced Module List/i);
|
||||
expect(textarea).toBeInTheDocument();
|
||||
fireEvent.change(textarea, { target: { value: '[1, 2, 3]' } });
|
||||
expect(textarea.value).toBe('[1, 2, 3]');
|
||||
});
|
||||
});
|
||||
it('should display a warning alert', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const textarea = getByLabelText(/Advanced Module List/i);
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
expect(getByText(messages.buttonCancelText.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.buttonSaveText.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.alertWarning.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.alertWarningDescriptions.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should display a tooltip on clicking on the icon', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const button = getByLabelText(/Show help text/i);
|
||||
fireEvent.click(button);
|
||||
expect(getByText(/Enter the names of the advanced modules to use in your course./i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should change deprecated button text ', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
|
||||
expect(showDeprecatedItemsBtn).toBeInTheDocument();
|
||||
fireEvent.click(showDeprecatedItemsBtn);
|
||||
expect(getByText(/Hide Deprecated Settings/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
|
||||
});
|
||||
it('should reset to default value on click on Cancel button', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
});
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
expect(textarea.value).toBe('[3, 2, 1]');
|
||||
fireEvent.click(getByText(messages.buttonCancelText.defaultMessage));
|
||||
expect(textarea.value).toBe('[]');
|
||||
});
|
||||
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
});
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } });
|
||||
expect(textarea.value).toBe('[3, 2, 1,');
|
||||
fireEvent.click(getByText('Save changes'));
|
||||
fireEvent.click(getByText('Change manually'));
|
||||
expect(textarea.value).toBe('[3, 2, 1,');
|
||||
});
|
||||
it('should show success alert after save', async () => {
|
||||
const { getByLabelText, getByText } = render(<RootWrapper />);
|
||||
let textarea;
|
||||
await waitFor(() => {
|
||||
textarea = getByLabelText(/Advanced Module List/i);
|
||||
});
|
||||
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
|
||||
expect(textarea.value).toBe('[3, 2, 1]');
|
||||
axiosMock
|
||||
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
|
||||
.reply(200, {
|
||||
...advancedSettingsMock,
|
||||
advancedModules: {
|
||||
...advancedSettingsMock.advancedModules,
|
||||
value: [3, 2, 1],
|
||||
},
|
||||
});
|
||||
fireEvent.click(getByText('Save changes'));
|
||||
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
|
||||
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
16
src/advanced-settings/__mocks__/advancedSettings.js
Normal file
16
src/advanced-settings/__mocks__/advancedSettings.js
Normal file
@@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
advancedModules: {
|
||||
deprecated: false,
|
||||
displayName: 'Advanced Module List',
|
||||
help: 'Enter the names of the advanced modules to use in your course.',
|
||||
hideOnEnabledPublisher: false,
|
||||
value: [],
|
||||
},
|
||||
certHtmlViewEnabled: {
|
||||
deprecated: true,
|
||||
display_name: 'Certificate web/html view enabled',
|
||||
help: 'If true, certificate Web/HTML views are enabled for the course.',
|
||||
hide_on_enabled_publisher: false,
|
||||
value: true,
|
||||
},
|
||||
};
|
||||
2
src/advanced-settings/__mocks__/index.js
Normal file
2
src/advanced-settings/__mocks__/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as advancedSettingsMock } from './advancedSettings';
|
||||
41
src/advanced-settings/data/api.js
Normal file
41
src/advanced-settings/data/api.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { convertObjectToSnakeCase } from '../../utils';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getCourseAdvancedSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings/${courseId}`;
|
||||
const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/proctoring_errors/`;
|
||||
|
||||
/**
|
||||
* Get's advanced setting for a course.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getCourseAdvancedSettings(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates advanced setting for a course.
|
||||
* @param {string} courseId
|
||||
* @param {object} settings
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function updateCourseAdvancedSettings(courseId, settings) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets proctoring exam errors.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getProctoringExamErrors(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
5
src/advanced-settings/data/selectors.js
Normal file
5
src/advanced-settings/data/selectors.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export const getLoadingStatus = (state) => state.advancedSettings.loadingStatus;
|
||||
export const getCourseAppSettings = state => state.advancedSettings.courseAppSettings;
|
||||
export const getSavingStatus = (state) => state.advancedSettings.savingStatus;
|
||||
export const getProctoringExamErrors = (state) => state.advancedSettings.proctoringErrors;
|
||||
export const getSendRequestErrors = (state) => state.advancedSettings.sendRequestErrors.developer_message;
|
||||
48
src/advanced-settings/data/slice.js
Normal file
48
src/advanced-settings/data/slice.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'advancedSettings',
|
||||
initialState: {
|
||||
loadingStatus: RequestStatus.IN_PROGRESS,
|
||||
savingStatus: '',
|
||||
courseAppSettings: {},
|
||||
proctoringErrors: {},
|
||||
sendRequestErrors: {},
|
||||
},
|
||||
reducers: {
|
||||
updateLoadingStatus: (state, { payload }) => {
|
||||
state.loadingStatus = payload.status;
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
fetchCourseAppsSettingsSuccess: (state, { payload }) => {
|
||||
Object.assign(state.courseAppSettings, payload);
|
||||
},
|
||||
updateCourseAppsSettingsSuccess: (state, { payload }) => {
|
||||
Object.assign(state.courseAppSettings, payload);
|
||||
},
|
||||
getDataSendErrors: (state, { payload }) => {
|
||||
Object.assign(state.sendRequestErrors, payload);
|
||||
},
|
||||
fetchProctoringExamErrorsSuccess: (state, { payload }) => {
|
||||
Object.assign(state.proctoringErrors, payload);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
getDataSendErrors,
|
||||
fetchCourseAppsSettingsSuccess,
|
||||
updateCourseAppsSettingsSuccess,
|
||||
fetchProctoringExamErrorsSuccess,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
85
src/advanced-settings/data/thunks.js
Normal file
85
src/advanced-settings/data/thunks.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import {
|
||||
getCourseAdvancedSettings,
|
||||
updateCourseAdvancedSettings,
|
||||
getProctoringExamErrors,
|
||||
} from './api';
|
||||
import {
|
||||
fetchCourseAppsSettingsSuccess,
|
||||
updateCourseAppsSettingsSuccess,
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
fetchProctoringExamErrorsSuccess,
|
||||
getDataSendErrors,
|
||||
} from './slice';
|
||||
|
||||
export function fetchCourseAppSettings(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const settingValues = await getCourseAdvancedSettings(courseId);
|
||||
const sortedDisplayName = [];
|
||||
Object.values(settingValues).forEach(value => {
|
||||
const { displayName } = value;
|
||||
sortedDisplayName.push(displayName);
|
||||
});
|
||||
const sortedSettingValues = {};
|
||||
sortedDisplayName.sort().forEach((displayName => {
|
||||
Object.entries(settingValues).forEach(([key, value]) => {
|
||||
if (value.displayName === displayName) {
|
||||
sortedSettingValues[key] = value;
|
||||
}
|
||||
});
|
||||
}));
|
||||
dispatch(fetchCourseAppsSettingsSuccess(sortedSettingValues));
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 403) {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED }));
|
||||
} else {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCourseAppSetting(courseId, settings) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const settingValues = await updateCourseAdvancedSettings(courseId, settings);
|
||||
dispatch(updateCourseAppsSettingsSuccess(settingValues));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
let errorData;
|
||||
try {
|
||||
const { customAttributes: { httpErrorResponseData } } = error;
|
||||
errorData = JSON.parse(httpErrorResponseData);
|
||||
} catch (err) {
|
||||
errorData = {};
|
||||
}
|
||||
|
||||
dispatch(getDataSendErrors(errorData));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchProctoringExamErrors(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const settingValues = await getProctoringExamErrors(courseId);
|
||||
dispatch(fetchProctoringExamErrorsSuccess(settingValues));
|
||||
return true;
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
62
src/advanced-settings/hooks.js
Normal file
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,
|
||||
};
|
||||
};
|
||||
2
src/advanced-settings/index.js
Normal file
2
src/advanced-settings/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as AdvancedSettings } from './AdvancedSettings';
|
||||
86
src/advanced-settings/messages.js
Normal file
86
src/advanced-settings/messages.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
headingTitle: {
|
||||
id: 'course-authoring.advanced-settings.heading.title',
|
||||
defaultMessage: 'Advanced settings',
|
||||
},
|
||||
headingSubtitle: {
|
||||
id: 'course-authoring.advanced-settings.heading.subtitle',
|
||||
defaultMessage: 'Settings',
|
||||
},
|
||||
policy: {
|
||||
id: 'course-authoring.advanced-settings.policies.title',
|
||||
defaultMessage: 'Manual policy definition',
|
||||
},
|
||||
alertWarning: {
|
||||
id: 'course-authoring.advanced-settings.alert.warning',
|
||||
defaultMessage: "You've made some changes",
|
||||
},
|
||||
alertWarningDescriptions: {
|
||||
id: 'course-authoring.advanced-settings.alert.warning.descriptions',
|
||||
defaultMessage: 'Your changes will not take effect until you save your progress. Take care with key and value formatting, as validation is not implemented.',
|
||||
},
|
||||
alertSuccess: {
|
||||
id: 'course-authoring.advanced-settings.alert.success',
|
||||
defaultMessage: 'Your policy changes have been saved.',
|
||||
},
|
||||
alertSuccessDescriptions: {
|
||||
id: 'course-authoring.advanced-settings.alert.success.descriptions',
|
||||
defaultMessage: 'No validation is performed on policy keys or value pairs. If you are having difficulties, check your formatting.',
|
||||
},
|
||||
alertProctoringError: {
|
||||
id: 'course-authoring.advanced-settings.alert.proctoring.error',
|
||||
defaultMessage: 'This course has protected exam setting that are incomplete or invalid.',
|
||||
},
|
||||
alertProctoringErrorDescriptions: {
|
||||
id: 'course-authoring.advanced-settings.alert.proctoring.error.descriptions',
|
||||
defaultMessage: 'You will be unable to make changes until the following setting are updated on the page below.',
|
||||
},
|
||||
buttonSaveText: {
|
||||
id: 'course-authoring.advanced-settings.alert.button.save',
|
||||
defaultMessage: 'Save changes',
|
||||
},
|
||||
buttonSavingText: {
|
||||
id: 'course-authoring.advanced-settings.alert.button.saving',
|
||||
defaultMessage: 'Saving',
|
||||
},
|
||||
buttonCancelText: {
|
||||
id: 'course-authoring.advanced-settings.alert.button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
deprecatedButtonShowText: {
|
||||
id: 'course-authoring.advanced-settings.deprecated.button.show',
|
||||
defaultMessage: 'Show',
|
||||
},
|
||||
deprecatedButtonHideText: {
|
||||
id: 'course-authoring.advanced-settings.deprecated.button.hide',
|
||||
defaultMessage: 'Hide',
|
||||
},
|
||||
alertWarningAriaLabelledby: {
|
||||
id: 'course-authoring.advanced-settings.alert.warning.aria.labelledby',
|
||||
defaultMessage: 'notification-warning-title',
|
||||
},
|
||||
alertWarningAriaDescribedby: {
|
||||
id: 'course-authoring.advanced-settings.alert.warning.aria.describedby',
|
||||
defaultMessage: 'notification-warning-description',
|
||||
},
|
||||
alertSuccessAriaLabelledby: {
|
||||
id: 'course-authoring.advanced-settings.alert.success.aria.labelledby',
|
||||
defaultMessage: 'alert-confirmation-title',
|
||||
},
|
||||
alertSuccessAriaDescribedby: {
|
||||
id: 'course-authoring.advanced-settings.alert.success.aria.describedby',
|
||||
defaultMessage: 'alert-confirmation-description',
|
||||
},
|
||||
alertProctoringAriaLabelledby: {
|
||||
id: 'course-authoring.advanced-settings.alert.proctoring.error.aria.labelledby',
|
||||
defaultMessage: 'alert-danger-title',
|
||||
},
|
||||
alertProctoringDescribedby: {
|
||||
id: 'course-authoring.advanced-settings.alert.proctoring.error.aria.describedby',
|
||||
defaultMessage: 'alert-danger-description',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
63
src/advanced-settings/modal-error/ModalError.jsx
Normal file
63
src/advanced-settings/modal-error/ModalError.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
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 = ({
|
||||
intl, isError, handleUndoChanges, showErrorModal, errorList, settingsData,
|
||||
}) => (
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.modalErrorTitle)}
|
||||
isOpen={isError}
|
||||
variant="danger"
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => showErrorModal(!isError)}
|
||||
>
|
||||
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
|
||||
</Button>
|
||||
<Button onClick={handleUndoChanges}>
|
||||
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.modal.error.description"
|
||||
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
|
||||
Please check the following validation feedbacks and reflect them in your course settings:"
|
||||
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
|
||||
/>
|
||||
</p>
|
||||
<hr />
|
||||
<ul className="p-0">
|
||||
{errorList.map((settingName) => (
|
||||
<ModalErrorListItem
|
||||
key={settingName.key}
|
||||
settingName={settingName}
|
||||
settingsData={settingsData}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</AlertModal>
|
||||
);
|
||||
|
||||
ModalError.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
isError: PropTypes.bool.isRequired,
|
||||
handleUndoChanges: PropTypes.func.isRequired,
|
||||
showErrorModal: PropTypes.func.isRequired,
|
||||
errorList: PropTypes.arrayOf(PropTypes.shape({
|
||||
key: PropTypes.string,
|
||||
message: PropTypes.string,
|
||||
})).isRequired,
|
||||
settingsData: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ModalError);
|
||||
58
src/advanced-settings/modal-error/ModalError.test.jsx
Normal file
58
src/advanced-settings/modal-error/ModalError.test.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ModalError from './ModalError';
|
||||
import messages from './messages';
|
||||
|
||||
const handleUndoChangesMock = jest.fn();
|
||||
const showErrorModalMock = jest.fn();
|
||||
|
||||
const errorList = [
|
||||
{ key: 'setting1', message: 'Error 1' },
|
||||
{ key: 'setting2', message: 'Error 2' },
|
||||
];
|
||||
|
||||
const settingsData = {
|
||||
setting1: 'value1',
|
||||
setting2: 'value2',
|
||||
};
|
||||
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en">
|
||||
<ModalError
|
||||
isError
|
||||
handleUndoChanges={handleUndoChangesMock}
|
||||
showErrorModal={showErrorModalMock}
|
||||
errorList={errorList}
|
||||
settingsData={settingsData}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('<ModalError />', () => {
|
||||
it('calls handleUndoChanges when "Undo changes" button is clicked', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const undoChangesButton = getByText(messages.modalErrorButtonUndoChanges.defaultMessage);
|
||||
fireEvent.click(undoChangesButton);
|
||||
expect(handleUndoChangesMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('calls showErrorModal when "Change manually" button is clicked', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const changeManuallyButton = getByText(messages.modalErrorButtonChangeManually.defaultMessage);
|
||||
fireEvent.click(changeManuallyButton);
|
||||
expect(showErrorModalMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('renders error message with correct values', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText(/There was/i)).toBeInTheDocument();
|
||||
expect(getByText(/2 validation error/i)).toBeInTheDocument();
|
||||
expect(getByText(/while trying to save the course settings in the database. Please check the following validation feedbacks and reflect them in your course settings:/i)).toBeInTheDocument();
|
||||
expect(getByText(messages.modalErrorTitle.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
it('renders correct number of errors', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText('Error 1')).toBeInTheDocument();
|
||||
expect(getByText('Error 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
31
src/advanced-settings/modal-error/ModalErrorListItem.jsx
Normal file
31
src/advanced-settings/modal-error/ModalErrorListItem.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Icon } from '@edx/paragon';
|
||||
import { Error } from '@edx/paragon/icons';
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
import { transformKeysToCamelCase } from '../../utils';
|
||||
|
||||
const ModalErrorListItem = ({ settingName, settingsData }) => {
|
||||
const { displayName } = settingsData[transformKeysToCamelCase(settingName)];
|
||||
return (
|
||||
<li className="modal-error-item">
|
||||
<Alert variant="danger">
|
||||
<h4 className="modal-error-item-title">
|
||||
<Icon src={Error} />{capitalize(displayName)}:
|
||||
</h4>
|
||||
<p className="m-0">{settingName.message}</p>
|
||||
</Alert>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
ModalErrorListItem.propTypes = {
|
||||
settingName: PropTypes.shape({
|
||||
key: PropTypes.string,
|
||||
message: PropTypes.string,
|
||||
}).isRequired,
|
||||
settingsData: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default ModalErrorListItem;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import ModalErrorListItem from './ModalErrorListItem';
|
||||
|
||||
const settingName = {
|
||||
key: 'exampleKey',
|
||||
message: 'Error message',
|
||||
};
|
||||
|
||||
const settingsData = {
|
||||
exampleKey: {
|
||||
displayName: 'Error field',
|
||||
},
|
||||
};
|
||||
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en">
|
||||
<ModalErrorListItem settingName={settingName} settingsData={settingsData} />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('<ModalErrorListItem />', () => {
|
||||
it('renders the display name and error message', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText('Error field:')).toBeInTheDocument();
|
||||
expect(getByText('Error message')).toBeInTheDocument();
|
||||
});
|
||||
it('renders the alert with variant "danger"', () => {
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
expect(getByRole('alert')).toHaveClass('alert-danger');
|
||||
});
|
||||
});
|
||||
18
src/advanced-settings/modal-error/messages.js
Normal file
18
src/advanced-settings/modal-error/messages.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
modalErrorTitle: {
|
||||
id: 'course-authoring.advanced-settings.modal.error.title',
|
||||
defaultMessage: 'Validation error while saving',
|
||||
},
|
||||
modalErrorButtonChangeManually: {
|
||||
id: 'course-authoring.advanced-settings.modal.error.btn.change-manually',
|
||||
defaultMessage: 'Change manually',
|
||||
},
|
||||
modalErrorButtonUndoChanges: {
|
||||
id: 'course-authoring.advanced-settings.modal.error.btn.undo-changes',
|
||||
defaultMessage: 'Undo changes',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
125
src/advanced-settings/scss/AdvancedSettings.scss
Normal file
125
src/advanced-settings/scss/AdvancedSettings.scss
Normal file
@@ -0,0 +1,125 @@
|
||||
@import "variables";
|
||||
|
||||
.setting-items-policies {
|
||||
.setting-items-deprecated-setting {
|
||||
float: right;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-toast {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 0 .625rem;
|
||||
z-index: $zindex-modal;
|
||||
}
|
||||
|
||||
.alert-proctoring-error {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.setting-items-list {
|
||||
li {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
min-height: 2.75rem;
|
||||
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 {
|
||||
padding: .625rem;
|
||||
}
|
||||
|
||||
.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 $font-weight-bold 1.125rem/1.5rem $font-family-base;
|
||||
color: $headings-color;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.setting-sidebar-supplementary-about-descriptions {
|
||||
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
|
||||
color: $text-color-base;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-sidebar-supplementary-other-links ul {
|
||||
list-style: none;
|
||||
|
||||
.setting-sidebar-supplementary-other-link {
|
||||
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
|
||||
line-height: 1.5rem;
|
||||
color: $info-500;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-sidebar-supplementary-other-title {
|
||||
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
|
||||
color: $headings-color;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-error-item {
|
||||
list-style: none;
|
||||
|
||||
.pgn__icon {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
margin-bottom: 5px;
|
||||
color: $danger;
|
||||
}
|
||||
|
||||
.modal-error-item-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-popup-content {
|
||||
max-width: 200px;
|
||||
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: $black;
|
||||
}
|
||||
2
src/advanced-settings/scss/_variables.scss
Normal file
2
src/advanced-settings/scss/_variables.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
$text-color-base: $gray-700;
|
||||
$setting-form-control-width: 34.375rem;
|
||||
138
src/advanced-settings/setting-card/SettingCard.jsx
Normal file
138
src/advanced-settings/setting-card/SettingCard.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ActionRow,
|
||||
Card,
|
||||
Form,
|
||||
Icon,
|
||||
IconButton,
|
||||
ModalPopup,
|
||||
useToggle,
|
||||
} from '@edx/paragon';
|
||||
import { InfoOutline, Warning } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { capitalize } from 'lodash';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const SettingCard = ({
|
||||
name,
|
||||
settingData,
|
||||
handleBlur,
|
||||
setEdited,
|
||||
showSaveSettingsPrompt,
|
||||
saveSettingsPrompt,
|
||||
isEditableState,
|
||||
setIsEditableState,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const { deprecated, help, displayName } = settingData;
|
||||
const initialValue = JSON.stringify(settingData.value, null, 4);
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState(null);
|
||||
const [newValue, setNewValue] = useState(initialValue);
|
||||
|
||||
const handleSettingChange = (e) => {
|
||||
const { value } = e.target;
|
||||
setNewValue(e.target.value);
|
||||
if (value !== initialValue) {
|
||||
if (!saveSettingsPrompt) {
|
||||
showSaveSettingsPrompt(true);
|
||||
}
|
||||
if (!isEditableState) {
|
||||
setIsEditableState(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardBlur = () => {
|
||||
setEdited((prevEditedSettings) => ({
|
||||
...prevEditedSettings,
|
||||
[name]: newValue,
|
||||
}));
|
||||
handleBlur();
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="field-group course-advanced-policy-list-item">
|
||||
<Card className="flex-column setting-card">
|
||||
<Card.Body className="d-flex">
|
||||
<Card.Header
|
||||
title={(
|
||||
<ActionRow>
|
||||
{capitalize(displayName)}
|
||||
<IconButton
|
||||
ref={setTarget}
|
||||
onClick={open}
|
||||
src={InfoOutline}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.helpButtonText)}
|
||||
variant="primary"
|
||||
className=" ml-1 mr-2"
|
||||
/>
|
||||
<ModalPopup
|
||||
hasArrow
|
||||
placement="right"
|
||||
positionRef={target}
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
className="pgn__modal-popup__arrow"
|
||||
>
|
||||
<div
|
||||
className="p-2 x-small rounded modal-popup-content"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: help }}
|
||||
/>
|
||||
</ModalPopup>
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
<Card.Section>
|
||||
<Form.Group className="m-0">
|
||||
<Form.Control
|
||||
as={TextareaAutosize}
|
||||
value={isEditableState ? newValue : initialValue}
|
||||
name={name}
|
||||
onChange={handleSettingChange}
|
||||
aria-label={displayName}
|
||||
onBlur={handleCardBlur}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Card.Section>
|
||||
</Card.Body>
|
||||
{deprecated && (
|
||||
<Card.Status icon={Warning} variant="danger">
|
||||
{intl.formatMessage(messages.deprecated)}
|
||||
</Card.Status>
|
||||
)}
|
||||
</Card>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
SettingCard.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
settingData: PropTypes.shape({
|
||||
deprecated: PropTypes.bool,
|
||||
help: PropTypes.string,
|
||||
displayName: PropTypes.string,
|
||||
value: PropTypes.PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
PropTypes.number,
|
||||
PropTypes.object,
|
||||
PropTypes.array,
|
||||
]),
|
||||
}).isRequired,
|
||||
setEdited: PropTypes.func.isRequired,
|
||||
showSaveSettingsPrompt: PropTypes.func.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
handleBlur: PropTypes.func.isRequired,
|
||||
saveSettingsPrompt: PropTypes.bool.isRequired,
|
||||
isEditableState: PropTypes.bool.isRequired,
|
||||
setIsEditableState: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SettingCard);
|
||||
96
src/advanced-settings/setting-card/SettingCard.test.jsx
Normal file
96
src/advanced-settings/setting-card/SettingCard.test.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
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';
|
||||
|
||||
import SettingCard from './SettingCard';
|
||||
import messages from './messages';
|
||||
|
||||
const setEdited = jest.fn();
|
||||
const showSaveSettingsPrompt = jest.fn();
|
||||
const setIsEditableState = jest.fn();
|
||||
const handleBlur = jest.fn();
|
||||
|
||||
const settingData = {
|
||||
deprecated: false,
|
||||
help: 'This is a help message',
|
||||
displayName: 'Setting Name',
|
||||
value: 'Setting Value',
|
||||
};
|
||||
|
||||
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
<textarea
|
||||
{...props}
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
)));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en">
|
||||
<SettingCard
|
||||
intl={{}}
|
||||
isOn
|
||||
name="settingName"
|
||||
setEdited={setEdited}
|
||||
setIsEditableState={setIsEditableState}
|
||||
showSaveSettingsPrompt={showSaveSettingsPrompt}
|
||||
settingData={settingData}
|
||||
handleBlur={handleBlur}
|
||||
isEditableState
|
||||
saveSettingsPrompt={false}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('<SettingCard />', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
it('renders the setting card with the provided data', () => {
|
||||
const { getByText, getByLabelText } = render(<RootWrapper />);
|
||||
const cardTitle = getByText(/Setting Name/i);
|
||||
const input = getByLabelText(/Setting Name/i);
|
||||
expect(cardTitle).toBeInTheDocument();
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.value).toBe(JSON.stringify(settingData.value, null, 4));
|
||||
});
|
||||
it('displays the deprecated status when the setting is deprecated', () => {
|
||||
const deprecatedSettingData = { ...settingData, deprecated: true };
|
||||
const { getByText } = render(
|
||||
<IntlProvider locale="en">
|
||||
<SettingCard
|
||||
intl={{}}
|
||||
isOn
|
||||
name="settingName"
|
||||
setEdited={setEdited}
|
||||
setIsEditableState={setIsEditableState}
|
||||
showSaveSettingsPrompt={showSaveSettingsPrompt}
|
||||
settingData={deprecatedSettingData}
|
||||
handleBlur={handleBlur}
|
||||
isEditable={false}
|
||||
saveSettingsPrompt
|
||||
/>
|
||||
</IntlProvider>,
|
||||
);
|
||||
const deprecatedStatus = getByText(messages.deprecated.defaultMessage);
|
||||
expect(deprecatedStatus).toBeInTheDocument();
|
||||
});
|
||||
it('does not display the deprecated status when the setting is not deprecated', () => {
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
expect(queryByText(messages.deprecated.defaultMessage)).toBeNull();
|
||||
});
|
||||
it('calls setEdited on blur', async () => {
|
||||
const { getByLabelText } = render(<RootWrapper />);
|
||||
const inputBox = getByLabelText(/Setting Name/i);
|
||||
fireEvent.focus(inputBox);
|
||||
userEvent.clear(inputBox);
|
||||
userEvent.type(inputBox, '3, 2, 1');
|
||||
await waitFor(() => {
|
||||
expect(inputBox).toHaveValue('3, 2, 1');
|
||||
});
|
||||
await (async () => {
|
||||
expect(setEdited).toHaveBeenCalled();
|
||||
expect(handleBlur).toHaveBeenCalled();
|
||||
});
|
||||
fireEvent.focusOut(inputBox);
|
||||
});
|
||||
});
|
||||
14
src/advanced-settings/setting-card/messages.js
Normal file
14
src/advanced-settings/setting-card/messages.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
deprecated: {
|
||||
id: 'course-authoring.advanced-settings.button.deprecated',
|
||||
defaultMessage: 'Deprecated',
|
||||
},
|
||||
helpButtonText: {
|
||||
id: 'course-authoring.advanced-settings.button.help',
|
||||
defaultMessage: 'Show help text',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
47
src/advanced-settings/settings-sidebar/SettingsSidebar.jsx
Normal file
47
src/advanced-settings/settings-sidebar/SettingsSidebar.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FormattedMessage,
|
||||
injectIntl,
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import HelpSidebar from '../../generic/help-sidebar';
|
||||
import messages from './messages';
|
||||
|
||||
const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => (
|
||||
<HelpSidebar
|
||||
courseId={courseId}
|
||||
proctoredExamSettingsUrl={proctoredExamSettingsUrl}
|
||||
showOtherSettings
|
||||
>
|
||||
<h4 className="help-sidebar-about-title">
|
||||
{intl.formatMessage(messages.about)}
|
||||
</h4>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
{intl.formatMessage(messages.aboutDescription1)}
|
||||
</p>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
{intl.formatMessage(messages.aboutDescription2)}
|
||||
</p>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
<FormattedMessage
|
||||
id="course-authoring.advanced-settings.about.description-3"
|
||||
defaultMessage="{notice} When you enter strings as policy values, ensure that you use double quotation marks (“) around the string. Do not use single quotation marks (‘)."
|
||||
values={{ notice: <strong>Note:</strong> }}
|
||||
/>
|
||||
</p>
|
||||
</HelpSidebar>
|
||||
);
|
||||
|
||||
SettingsSidebar.defaultProps = {
|
||||
proctoredExamSettingsUrl: '',
|
||||
};
|
||||
|
||||
SettingsSidebar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
proctoredExamSettingsUrl: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(SettingsSidebar);
|
||||
@@ -0,0 +1,46 @@
|
||||
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(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
it('renders about and other sidebar titles correctly', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText(messages.about.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.other.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
it('renders about descriptions correctly', () => {
|
||||
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();
|
||||
expect(aboutThirtyDescription).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
47
src/advanced-settings/settings-sidebar/messages.js
Normal file
47
src/advanced-settings/settings-sidebar/messages.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
about: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.about.title',
|
||||
defaultMessage: 'What do advanced settings do?',
|
||||
},
|
||||
aboutDescription1: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.about.description-1',
|
||||
defaultMessage: 'Advanced settings control specific course functionality. On this page, you can edit manual policies, which are JSON-based key and value pairs that control specific course settings.',
|
||||
},
|
||||
aboutDescription2: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.about.description-2',
|
||||
defaultMessage: 'Any policies you modify here override all other information you’ve defined elsewhere in Studio. Do not edit policies unless you are familiar with both their purpose and syntax.',
|
||||
},
|
||||
other: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.other.title',
|
||||
defaultMessage: 'Other course settings',
|
||||
},
|
||||
otherCourseSettingsLinkToScheduleAndDetails: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.links.schedule-and-details',
|
||||
defaultMessage: 'Details & schedule',
|
||||
description: 'Link to Studio Details & schedule page',
|
||||
},
|
||||
otherCourseSettingsLinkToGrading: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.links.grading',
|
||||
defaultMessage: 'Grading',
|
||||
description: 'Link to Studio Grading page',
|
||||
},
|
||||
otherCourseSettingsLinkToCourseTeam: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.links.course-team',
|
||||
defaultMessage: 'Course team',
|
||||
description: 'Link to Studio Course team page',
|
||||
},
|
||||
otherCourseSettingsLinkToGroupConfigurations: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.links.group-configurations',
|
||||
defaultMessage: 'Group configurations',
|
||||
description: 'Link to Studio Group configurations page',
|
||||
},
|
||||
otherCourseSettingsLinkToProctoredExamSettings: {
|
||||
id: 'course-authoring.advanced-settings.sidebar.links.proctored-exam-settings',
|
||||
defaultMessage: 'Proctored exam settings',
|
||||
description: 'Link to Proctored exam settings page',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
48
src/advanced-settings/utils.js
Normal file
48
src/advanced-settings/utils.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Validates advanced settings data by checking if the provided settings are correctly formatted JSON.
|
||||
* It performs validation on a given object of settings, detects incorrectly formatted settings,
|
||||
* and sets error fields accordingly using the setErrorFields function.
|
||||
*
|
||||
* @param {object} settingObj - The object containing the settings to validate.
|
||||
* @param {function} setErrorFields - The function to set error fields.
|
||||
* @returns {boolean} - `true` if the data is valid, otherwise `false`.
|
||||
*/
|
||||
export default function validateAdvancedSettingsData(settingObj, setErrorFields, setEditedSettings) {
|
||||
const fieldsWithErrors = [];
|
||||
|
||||
const pushDataToErrorArray = (settingName) => {
|
||||
fieldsWithErrors.push({ key: settingName, message: 'Incorrectly formatted JSON' });
|
||||
};
|
||||
|
||||
Object.entries(settingObj).forEach(([settingName, settingValue]) => {
|
||||
try {
|
||||
JSON.parse(settingValue);
|
||||
} catch (e) {
|
||||
let targetSettingValue = settingValue;
|
||||
const firstNonWhite = settingValue.substring(0, 1);
|
||||
const isValid = !['{', '[', "'"].includes(firstNonWhite);
|
||||
|
||||
if (isValid) {
|
||||
try {
|
||||
targetSettingValue = `"${ targetSettingValue.trim() }"`;
|
||||
JSON.parse(targetSettingValue);
|
||||
setEditedSettings((prevEditedSettings) => ({
|
||||
...prevEditedSettings,
|
||||
[settingName]: targetSettingValue,
|
||||
}));
|
||||
} catch (quotedE) { /* empty */ }
|
||||
}
|
||||
|
||||
pushDataToErrorArray(settingName);
|
||||
}
|
||||
});
|
||||
|
||||
setErrorFields((prevState) => {
|
||||
if (JSON.stringify(prevState) !== JSON.stringify(fieldsWithErrors)) {
|
||||
return fieldsWithErrors;
|
||||
}
|
||||
return prevState;
|
||||
});
|
||||
|
||||
return fieldsWithErrors.length === 0;
|
||||
}
|
||||
29
src/advanced-settings/utils.test.js
Normal file
29
src/advanced-settings/utils.test.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import validateAdvancedSettingsData from './utils';
|
||||
|
||||
describe('validateAdvancedSettingsData', () => {
|
||||
it('should validate correctly formatted settings and return true', () => {
|
||||
const settingObj = {
|
||||
setting1: '{ "key": "value" }',
|
||||
setting2: '{ "key": "value" }',
|
||||
};
|
||||
const setErrorFieldsMock = jest.fn();
|
||||
const setEditedSettingsMock = jest.fn();
|
||||
const isValid = validateAdvancedSettingsData(settingObj, setErrorFieldsMock, setEditedSettingsMock);
|
||||
expect(isValid).toBe(true);
|
||||
expect(setErrorFieldsMock).toHaveBeenCalledTimes(1);
|
||||
expect(setEditedSettingsMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
it('should validate incorrectly formatted settings and set error fields', () => {
|
||||
const settingObj = {
|
||||
setting1: '{ "key": "value" }',
|
||||
setting2: 'incorrectJSON',
|
||||
setting3: '{ "key": "value" }',
|
||||
};
|
||||
const setErrorFieldsMock = jest.fn();
|
||||
const setEditedSettingsMock = jest.fn();
|
||||
const isValid = validateAdvancedSettingsData(settingObj, setErrorFieldsMock, setEditedSettingsMock);
|
||||
expect(isValid).toBe(false);
|
||||
expect(setErrorFieldsMock).toHaveBeenCalledTimes(1);
|
||||
expect(setEditedSettingsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
9
src/assets/scss/_animations.scss
Normal file
9
src/assets/scss/_animations.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
81
src/assets/scss/_form.scss
Normal file
81
src/assets/scss/_form.scss
Normal file
@@ -0,0 +1,81 @@
|
||||
.form-group-custom {
|
||||
.pgn__form-label {
|
||||
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 $font-weight-normal .75rem/1.25rem $font-family-base;
|
||||
color: $gray-500;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.form-group-custom_isInvalid {
|
||||
input {
|
||||
border-color: $form-feedback-invalid-color;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-error {
|
||||
color: $form-feedback-invalid-color;
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker-custom {
|
||||
margin: 0;
|
||||
|
||||
.datepicker-custom-control {
|
||||
display: block;
|
||||
width: 100%;
|
||||
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: $input-focus-color;
|
||||
background-color: $input-bg;
|
||||
border-color: $input-focus-border-color;
|
||||
box-shadow: $input-focus-box-shadow;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $input-placeholder-color;
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker-custom-control_readonly {
|
||||
border-color: transparent;
|
||||
background: $input-disabled-bg;
|
||||
}
|
||||
|
||||
.datepicker-custom-control_isInvalid {
|
||||
border-color: $form-feedback-invalid-color;
|
||||
}
|
||||
|
||||
.datepicker-custom-control-icon {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 1.188rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
3
src/assets/scss/_utilities.scss
Normal file
3
src/assets/scss/_utilities.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.text-black {
|
||||
color: $black;
|
||||
}
|
||||
2
src/assets/scss/_variables.scss
Normal file
2
src/assets/scss/_variables.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
$text-color-base: $gray-700;
|
||||
$text-color-weak: #3E3E3C;
|
||||
25
src/constants.js
Normal file
25
src/constants.js
Normal file
@@ -0,0 +1,25 @@
|
||||
export const DATE_FORMAT = 'MM/dd/yyyy';
|
||||
export const TIME_FORMAT = 'HH:mm';
|
||||
export const DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss\\Z';
|
||||
export const COMMA_SEPARATED_DATE_FORMAT = 'MMMM D, YYYY';
|
||||
export const DEFAULT_EMPTY_WYSIWYG_VALUE = '<p> </p>';
|
||||
export const STATEFUL_BUTTON_STATES = {
|
||||
pending: 'pending',
|
||||
default: 'default',
|
||||
};
|
||||
|
||||
export const USER_ROLES = {
|
||||
admin: 'instructor',
|
||||
staff: 'staff',
|
||||
};
|
||||
|
||||
export const BADGE_STATES = {
|
||||
danger: 'danger',
|
||||
secondary: 'secondary',
|
||||
};
|
||||
|
||||
export const NOTIFICATION_MESSAGES = {
|
||||
saving: 'Saving',
|
||||
duplicating: 'Duplicating',
|
||||
deleting: 'Deleting',
|
||||
};
|
||||
163
src/course-team/CourseTeam.jsx
Normal file
163
src/course-team/CourseTeam.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Layout,
|
||||
} from '@edx/paragon';
|
||||
import { Add as IconAdd } from '@edx/paragon/icons';
|
||||
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import { USER_ROLES } from '../constants';
|
||||
import messages from './messages';
|
||||
import CourseTeamSideBar from './course-team-sidebar/CourseTeamSidebar';
|
||||
import AddUserForm from './add-user-form/AddUserForm';
|
||||
import AddTeamMember from './add-team-member/AddTeamMember';
|
||||
import CourseTeamMember from './course-team-member/CourseTeamMember';
|
||||
import InfoModal from './info-modal/InfoModal';
|
||||
import { useCourseTeam } from './hooks';
|
||||
|
||||
const CourseTeam = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
modalType,
|
||||
errorMessage,
|
||||
courseName,
|
||||
currentEmail,
|
||||
courseTeamUsers,
|
||||
currentUserEmail,
|
||||
isLoading,
|
||||
isSingleAdmin,
|
||||
isFormVisible,
|
||||
isQueryPending,
|
||||
isAllowActions,
|
||||
isInfoModalOpen,
|
||||
isOwnershipHint,
|
||||
isShowAddTeamMember,
|
||||
isShowInitialSidebar,
|
||||
isShowUserFilledSidebar,
|
||||
isInternetConnectionAlertFailed,
|
||||
openForm,
|
||||
hideForm,
|
||||
closeInfoModal,
|
||||
handleAddUserSubmit,
|
||||
handleOpenDeleteModal,
|
||||
handleDeleteUserSubmit,
|
||||
handleChangeRoleUserSubmit,
|
||||
handleInternetConnectionFailed,
|
||||
} = useCourseTeam({ intl, courseId });
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container size="xl" className="m-4">
|
||||
<section className="course-team-container mb-4">
|
||||
<Layout
|
||||
lg={[{ span: 9 }, { span: 3 }]}
|
||||
md={[{ span: 9 }, { span: 3 }]}
|
||||
sm={[{ span: 9 }, { span: 3 }]}
|
||||
xs={[{ span: 9 }, { span: 3 }]}
|
||||
xl={[{ span: 9 }, { span: 3 }]}
|
||||
>
|
||||
<Layout.Element>
|
||||
<article>
|
||||
<div>
|
||||
<SubHeader
|
||||
title={intl.formatMessage(messages.headingTitle)}
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
headerActions={isAllowActions && (
|
||||
<Button
|
||||
variant="outline-success"
|
||||
iconBefore={IconAdd}
|
||||
size="sm"
|
||||
onClick={openForm}
|
||||
disabled={isFormVisible}
|
||||
>
|
||||
{intl.formatMessage(messages.addNewMemberButton)}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<section className="course-team-section">
|
||||
<div className="members-container">
|
||||
{isFormVisible && (
|
||||
<AddUserForm
|
||||
onSubmit={handleAddUserSubmit}
|
||||
onCancel={hideForm}
|
||||
/>
|
||||
)}
|
||||
{courseTeamUsers.length ? courseTeamUsers.map(({ username, role, email }) => (
|
||||
<CourseTeamMember
|
||||
key={email}
|
||||
userName={username}
|
||||
role={role}
|
||||
email={email}
|
||||
currentUserEmail={currentUserEmail || ''}
|
||||
isAllowActions={isAllowActions}
|
||||
isHideActions={role === USER_ROLES.admin && isSingleAdmin}
|
||||
onChangeRole={handleChangeRoleUserSubmit}
|
||||
onDelete={handleOpenDeleteModal}
|
||||
/>
|
||||
)) : null}
|
||||
{isShowAddTeamMember && (
|
||||
<AddTeamMember
|
||||
onFormOpen={openForm}
|
||||
isButtonDisable={isFormVisible}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isShowInitialSidebar && (
|
||||
<div className="sidebar-container">
|
||||
<CourseTeamSideBar
|
||||
courseId={courseId}
|
||||
isOwnershipHint={isOwnershipHint}
|
||||
isShowInitialSidebar={isShowInitialSidebar}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<InfoModal
|
||||
isOpen={isInfoModalOpen}
|
||||
close={closeInfoModal}
|
||||
currentEmail={currentEmail}
|
||||
errorMessage={errorMessage}
|
||||
courseName={courseName}
|
||||
modalType={modalType}
|
||||
onDeleteSubmit={handleDeleteUserSubmit}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</Layout.Element>
|
||||
<Layout.Element>
|
||||
{isShowUserFilledSidebar && (
|
||||
<CourseTeamSideBar
|
||||
courseId={courseId}
|
||||
isOwnershipHint={isOwnershipHint}
|
||||
/>
|
||||
)}
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
</section>
|
||||
</Container>
|
||||
<div className="alert-toast">
|
||||
<InternetConnectionAlert
|
||||
isFailed={isInternetConnectionAlertFailed}
|
||||
isQueryPending={isQueryPending}
|
||||
onInternetConnectionFailed={handleInternetConnectionFailed}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CourseTeam.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseTeam);
|
||||
23
src/course-team/CourseTeam.scss
Normal file
23
src/course-team/CourseTeam.scss
Normal file
@@ -0,0 +1,23 @@
|
||||
@import "./course-team-sidebar/CourseTeamSidebar";
|
||||
@import "./add-user-form/AddUserForm";
|
||||
@import "./add-team-member/AddTeamMember";
|
||||
@import "./course-team-member/CourseTeamMember";
|
||||
|
||||
.course-team-section {
|
||||
.sidebar-container {
|
||||
width: 30%;
|
||||
|
||||
.help-sidebar {
|
||||
margin-top: 0;
|
||||
|
||||
hr {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.members-container {
|
||||
flex-grow: 1;
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
}
|
||||
222
src/course-team/CourseTeam.test.jsx
Normal file
222
src/course-team/CourseTeam.test.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
cleanup,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import initializeStore from '../store';
|
||||
import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__';
|
||||
import { getCourseTeamApiUrl, updateCourseTeamUserApiUrl } from './data/api';
|
||||
import CourseTeam from './CourseTeam';
|
||||
import messages from './messages';
|
||||
import { USER_ROLES } from '../constants';
|
||||
import { executeThunk } from '../utils';
|
||||
import { changeRoleTeamUserQuery, deleteCourseTeamQuery } from './data/thunk';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
pathname: mockPathname,
|
||||
}),
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseTeam courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<CourseTeam />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('render CourseTeam component with 3 team members correctly', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamMock);
|
||||
|
||||
const {
|
||||
getByText, getByRole, getByTestId, queryAllByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByTestId('course-team-sidebar')).toBeInTheDocument();
|
||||
expect(queryAllByTestId('course-team-member')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('render CourseTeam component with 1 team member correctly', async () => {
|
||||
cleanup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithOneUser);
|
||||
|
||||
const {
|
||||
getByText, getByRole, getByTestId, getAllByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByTestId('course-team-sidebar')).toBeInTheDocument();
|
||||
expect(getAllByTestId('course-team-member')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('render CourseTeam component without team member correctly', async () => {
|
||||
cleanup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithoutUsers);
|
||||
|
||||
const {
|
||||
getByText, getByRole, getByTestId, queryAllByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByTestId('course-team-sidebar__initial')).toBeInTheDocument();
|
||||
expect(queryAllByTestId('course-team-member')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('render CourseTeam component with initial sidebar correctly', async () => {
|
||||
cleanup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithoutUsers);
|
||||
|
||||
const { getByTestId, queryByTestId } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('course-team-sidebar__initial')).toBeInTheDocument();
|
||||
expect(queryByTestId('course-team-sidebar')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('render CourseTeam component without initial sidebar correctly', async () => {
|
||||
cleanup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamMock);
|
||||
|
||||
const { getByTestId, queryByTestId } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('course-team-sidebar__initial')).not.toBeInTheDocument();
|
||||
expect(getByTestId('course-team-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays AddUserForm when clicking the "Add New Member" button', async () => {
|
||||
cleanup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithOneUser);
|
||||
|
||||
const { getByRole, queryByTestId } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('add-user-form')).not.toBeInTheDocument();
|
||||
const addButton = getByRole('button', { name: messages.addNewMemberButton.defaultMessage });
|
||||
fireEvent.click(addButton);
|
||||
expect(queryByTestId('add-user-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays AddUserForm when clicking the "Add a New Team member" button', async () => {
|
||||
cleanup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamWithOneUser);
|
||||
|
||||
const { getByRole, queryByTestId } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('add-user-form')).not.toBeInTheDocument();
|
||||
const addButton = getByRole('button', { name: 'Add a new team member' });
|
||||
fireEvent.click(addButton);
|
||||
expect(queryByTestId('add-user-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('not displays "Add New Member" and AddTeamMember component when isAllowActions is false', async () => {
|
||||
cleanup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...courseTeamWithOneUser,
|
||||
allowActions: false,
|
||||
});
|
||||
|
||||
const { queryByRole, queryByTestId } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByRole('button', { name: messages.addNewMemberButton.defaultMessage })).not.toBeInTheDocument();
|
||||
expect(queryByTestId('add-team-member')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete user', async () => {
|
||||
cleanup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamMock);
|
||||
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onDelete(updateCourseTeamUserApiUrl(courseId, 'staff@example.com'))
|
||||
.reply(200);
|
||||
|
||||
await executeThunk(deleteCourseTeamQuery(courseId, 'staff@example.com'), store.dispatch);
|
||||
expect(queryByText('staff@example.com')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should change role user', async () => {
|
||||
cleanup();
|
||||
axiosMock
|
||||
.onGet(getCourseTeamApiUrl(courseId))
|
||||
.reply(200, courseTeamMock);
|
||||
|
||||
const { getAllByText } = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onPut(updateCourseTeamUserApiUrl(courseId, 'staff@example.com', { role: USER_ROLES.admin }))
|
||||
.reply(200, { role: USER_ROLES.admin });
|
||||
|
||||
await executeThunk(changeRoleTeamUserQuery(courseId, 'staff@example.com', { role: USER_ROLES.admin }), store.dispatch);
|
||||
expect(getAllByText('Admin')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
24
src/course-team/__mocks__/courseTeam.js
Normal file
24
src/course-team/__mocks__/courseTeam.js
Normal file
@@ -0,0 +1,24 @@
|
||||
module.exports = {
|
||||
showTransferOwnershipHint: true,
|
||||
users: [
|
||||
{
|
||||
email: 'staff@example.com',
|
||||
id: '2',
|
||||
role: 'staff',
|
||||
username: 'Staff_Name',
|
||||
},
|
||||
{
|
||||
email: 'audit@example.com',
|
||||
id: '3',
|
||||
role: 'staff',
|
||||
username: 'Audit_Name',
|
||||
},
|
||||
{
|
||||
email: 'vladislav.keblysh@raccoongang.com',
|
||||
id: '1',
|
||||
role: 'instructor',
|
||||
username: 'Vladislav_Keblysh',
|
||||
},
|
||||
],
|
||||
allowActions: true,
|
||||
};
|
||||
12
src/course-team/__mocks__/courseTeamWithOneUser.js
Normal file
12
src/course-team/__mocks__/courseTeamWithOneUser.js
Normal file
@@ -0,0 +1,12 @@
|
||||
module.exports = {
|
||||
showTransferOwnershipHint: true,
|
||||
users: [
|
||||
{
|
||||
email: 'staff@example.com',
|
||||
id: '2',
|
||||
role: 'staff',
|
||||
username: 'Staff_Name',
|
||||
},
|
||||
],
|
||||
allowActions: true,
|
||||
};
|
||||
5
src/course-team/__mocks__/courseTeamWithoutUsers.js
Normal file
5
src/course-team/__mocks__/courseTeamWithoutUsers.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
showTransferOwnershipHint: true,
|
||||
users: [],
|
||||
allowActions: true,
|
||||
};
|
||||
3
src/course-team/__mocks__/index.js
Normal file
3
src/course-team/__mocks__/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as courseTeamMock } from './courseTeam';
|
||||
export { default as courseTeamWithOneUser } from './courseTeamWithOneUser';
|
||||
export { default as courseTeamWithoutUsers } from './courseTeamWithoutUsers';
|
||||
39
src/course-team/add-team-member/AddTeamMember.jsx
Normal file
39
src/course-team/add-team-member/AddTeamMember.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Add as IconAdd } from '@edx/paragon/icons';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const AddTeamMember = ({ onFormOpen, isButtonDisable }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="add-team-member bg-gray-100" data-testid="add-team-member">
|
||||
<div className="add-team-member-info">
|
||||
<h3 className="add-team-member-title font-weight-bold">{intl.formatMessage(messages.title)}</h3>
|
||||
<span className="text-gray-500 small">{intl.formatMessage(messages.description)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline-success"
|
||||
iconBefore={IconAdd}
|
||||
onClick={onFormOpen}
|
||||
disabled={isButtonDisable}
|
||||
>
|
||||
{intl.formatMessage(messages.button)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AddTeamMember.propTypes = {
|
||||
onFormOpen: PropTypes.func.isRequired,
|
||||
isButtonDisable: PropTypes.bool,
|
||||
};
|
||||
|
||||
AddTeamMember.defaultProps = {
|
||||
isButtonDisable: false,
|
||||
};
|
||||
|
||||
export default AddTeamMember;
|
||||
17
src/course-team/add-team-member/AddTeamMember.scss
Normal file
17
src/course-team/add-team-member/AddTeamMember.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
.add-team-member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: .0625rem solid $gray-200;
|
||||
border-radius: .375rem;
|
||||
box-shadow: inset inset 0 1px .125rem 1px $gray-200;
|
||||
padding: 1.25rem 1.875rem;
|
||||
|
||||
.add-team-member-info {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.add-team-member-title {
|
||||
font-size: $spacer;
|
||||
}
|
||||
}
|
||||
46
src/course-team/add-team-member/AddTeamMember.test.jsx
Normal file
46
src/course-team/add-team-member/AddTeamMember.test.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import AddTeamMember from './AddTeamMember';
|
||||
import messages from './messages';
|
||||
|
||||
const onFormOpenMock = jest.fn();
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<IntlProvider locale="en">
|
||||
<AddTeamMember onFormOpen={onFormOpenMock} {...props} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
describe('<AddTeamMember />', () => {
|
||||
it('render AddTeamMember component correctly', () => {
|
||||
const { getByText, getByRole } = renderComponent();
|
||||
|
||||
expect(getByText(messages.title.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.description.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.button.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onFormOpen when the button is clicked', () => {
|
||||
const { getByText } = renderComponent();
|
||||
|
||||
const addButton = getByText(messages.button.defaultMessage);
|
||||
fireEvent.click(addButton);
|
||||
expect(onFormOpenMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('"Add a New Team member" button is disabled when isButtonDisable is true', () => {
|
||||
const { getByRole } = renderComponent({ isButtonDisable: true });
|
||||
|
||||
const addButton = getByRole('button', { name: messages.button.defaultMessage });
|
||||
expect(addButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('"Add a New Team member" button is not disabled when isButtonDisable is false', () => {
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const addButton = getByRole('button', { name: messages.button.defaultMessage });
|
||||
expect(addButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
18
src/course-team/add-team-member/messages.js
Normal file
18
src/course-team/add-team-member/messages.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'course-authoring.course-team.add-team-member.title',
|
||||
defaultMessage: 'Add team members to this course',
|
||||
},
|
||||
description: {
|
||||
id: 'course-authoring.course-team.add-team-member.description',
|
||||
defaultMessage: 'Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.',
|
||||
},
|
||||
button: {
|
||||
id: 'course-authoring.course-team.add-team-member.button',
|
||||
defaultMessage: 'Add a new team member',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
66
src/course-team/add-user-form/AddUserForm.jsx
Normal file
66
src/course-team/add-user-form/AddUserForm.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
ActionRow,
|
||||
} from '@edx/paragon';
|
||||
import { Formik } from 'formik';
|
||||
|
||||
import messages from './messages';
|
||||
import FormikControl from '../../generic/FormikControl';
|
||||
import { EXAMPLE_USER_EMAIL } from '../constants';
|
||||
|
||||
const AddUserForm = ({ onSubmit, onCancel }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="add-user-form" data-testid="add-user-form">
|
||||
<Formik
|
||||
initialValues={{ email: '' }}
|
||||
onSubmit={onSubmit}
|
||||
validateOnBlur
|
||||
>
|
||||
{({ handleSubmit, values }) => (
|
||||
<>
|
||||
<Form.Group size="sm" className="form-field">
|
||||
<h3 className="form-title">{intl.formatMessage(messages.formTitle)}</h3>
|
||||
<Form.Label size="sm" className="form-label font-weight-bold">
|
||||
{intl.formatMessage(messages.formLabel)}
|
||||
</Form.Label>
|
||||
<FormikControl
|
||||
name="email"
|
||||
value={values.email}
|
||||
placeholder={intl.formatMessage(messages.formPlaceholder, { email: EXAMPLE_USER_EMAIL })}
|
||||
/>
|
||||
<Form.Control.Feedback className="form-helper-text">
|
||||
{intl.formatMessage(messages.formHelperText)}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<ActionRow>
|
||||
<Button variant="tertiary" size="sm" onClick={onCancel}>
|
||||
{intl.formatMessage(messages.cancelButton)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!values.email.length}
|
||||
type="submit"
|
||||
>
|
||||
{intl.formatMessage(messages.addUserButton)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AddUserForm.propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AddUserForm;
|
||||
42
src/course-team/add-user-form/AddUserForm.scss
Normal file
42
src/course-team/add-user-form/AddUserForm.scss
Normal file
@@ -0,0 +1,42 @@
|
||||
.add-user-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: .0625rem solid $gray-200;
|
||||
border-radius: .375rem;
|
||||
box-shadow: 0 1px 1px $gray-200;
|
||||
margin-bottom: 1.25rem;
|
||||
background-color: $white;
|
||||
|
||||
.form-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.875rem;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
padding: 1.25rem 1.875rem;
|
||||
margin-bottom: $spacer;
|
||||
|
||||
.pgn__form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
margin-left: .3125rem;
|
||||
content: "*";
|
||||
}
|
||||
}
|
||||
|
||||
.form-helper-text {
|
||||
font-size: $font-size-xs;
|
||||
}
|
||||
|
||||
.pgn__action-row {
|
||||
padding: $spacer 1.875rem;
|
||||
border-top: .0625rem solid $gray-200;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
128
src/course-team/add-user-form/AddUserForm.test.jsx
Normal file
128
src/course-team/add-user-form/AddUserForm.test.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
act,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { EXAMPLE_USER_EMAIL } from '../constants';
|
||||
import initializeStore from '../../store';
|
||||
import { USER_ROLES } from '../../constants';
|
||||
import { updateCourseTeamUserApiUrl } from '../data/api';
|
||||
import { createCourseTeamQuery } from '../data/thunk';
|
||||
import { executeThunk } from '../../utils';
|
||||
import AddUserForm from './AddUserForm';
|
||||
import messages from './messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
pathname: mockPathname,
|
||||
}),
|
||||
}));
|
||||
|
||||
const onSubmitMock = jest.fn();
|
||||
const onCancelMock = jest.fn();
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<AddUserForm
|
||||
onSubmit={onSubmitMock}
|
||||
onCancel={onCancelMock}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<AddUserForm />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('render AddUserForm component correctly', () => {
|
||||
const { getByText, getByPlaceholderText } = render(<RootWrapper />);
|
||||
|
||||
expect(getByText(messages.formTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.formLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByPlaceholderText(messages.formPlaceholder.defaultMessage
|
||||
.replace('{email}', EXAMPLE_USER_EMAIL))).toBeInTheDocument();
|
||||
expect(getByText(messages.cancelButton.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.addUserButton.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSubmit when the "Add User" button is clicked with a valid email', async () => {
|
||||
const { getByPlaceholderText, getByRole } = render(<RootWrapper />);
|
||||
|
||||
const emailInput = getByPlaceholderText(messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL));
|
||||
const addUserButton = getByRole('button', { name: messages.addUserButton.defaultMessage });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: EXAMPLE_USER_EMAIL } });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(addUserButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmitMock).toHaveBeenCalledWith(
|
||||
{ email: EXAMPLE_USER_EMAIL },
|
||||
expect.objectContaining({ submitForm: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onPost(updateCourseTeamUserApiUrl(courseId, EXAMPLE_USER_EMAIL), { role: USER_ROLES.staff })
|
||||
.reply(200, { role: USER_ROLES.staff });
|
||||
|
||||
await executeThunk(createCourseTeamQuery(courseId, EXAMPLE_USER_EMAIL), store.dispatch);
|
||||
});
|
||||
|
||||
it('calls onCancel when the "Cancel" button is clicked', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
|
||||
const cancelButton = getByText(messages.cancelButton.defaultMessage);
|
||||
fireEvent.click(cancelButton);
|
||||
expect(onCancelMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('"Add User" button is disabled when the email input field is empty', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
|
||||
const addUserButton = getByText(messages.addUserButton.defaultMessage);
|
||||
expect(addUserButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('"Add User" button is not disabled when the email input field is not empty', () => {
|
||||
const { getByPlaceholderText, getByText } = render(<RootWrapper />);
|
||||
|
||||
const emailInput = getByPlaceholderText(
|
||||
messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL),
|
||||
);
|
||||
const addUserButton = getByText(messages.addUserButton.defaultMessage);
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'user@example.com' } });
|
||||
expect(addUserButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
31
src/course-team/add-user-form/messages.js
Normal file
31
src/course-team/add-user-form/messages.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
formTitle: {
|
||||
id: 'course-authoring.course-team.form.title',
|
||||
defaultMessage: 'Add a user to your course\'s team',
|
||||
},
|
||||
formLabel: {
|
||||
id: 'course-authoring.course-team.form.label',
|
||||
defaultMessage: 'User\'s email address',
|
||||
},
|
||||
formPlaceholder: {
|
||||
id: 'course-authoring.course-team.form.placeholder',
|
||||
defaultMessage: 'example: {email}',
|
||||
},
|
||||
formHelperText: {
|
||||
id: 'course-authoring.course-team.form.helperText',
|
||||
defaultMessage: 'Provide the email address of the user you want to add as Staff',
|
||||
},
|
||||
addUserButton: {
|
||||
id: 'course-authoring.course-team.form.button.addUser',
|
||||
defaultMessage: 'Add user',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'course-authoring.course-team.form.button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default messages;
|
||||
7
src/course-team/constants.js
Normal file
7
src/course-team/constants.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const MODAL_TYPES = {
|
||||
error: 'error',
|
||||
delete: 'delete',
|
||||
warning: 'warning',
|
||||
};
|
||||
|
||||
export const EXAMPLE_USER_EMAIL = 'username@domain.com';
|
||||
78
src/course-team/course-team-member/CourseTeamMember.jsx
Normal file
78
src/course-team/course-team-member/CourseTeamMember.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Badge, Button, MailtoLink } from '@edx/paragon';
|
||||
import { DeleteOutline as DeleteOutlineIcon } from '@edx/paragon/icons';
|
||||
|
||||
import messages from './messages';
|
||||
import { USER_ROLES, BADGE_STATES } from '../../constants';
|
||||
|
||||
const CourseTeamMember = ({
|
||||
userName,
|
||||
role,
|
||||
email,
|
||||
onChangeRole,
|
||||
onDelete,
|
||||
currentUserEmail,
|
||||
isHideActions,
|
||||
isAllowActions,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const isAdminRole = role === USER_ROLES.admin;
|
||||
|
||||
return (
|
||||
<div className="course-team-member" data-testid="course-team-member">
|
||||
<div className="member-info">
|
||||
<Badge variant={isAdminRole ? BADGE_STATES.danger : BADGE_STATES.secondary} className="badge-current-user">
|
||||
{isAdminRole
|
||||
? intl.formatMessage(messages.roleAdmin)
|
||||
: intl.formatMessage(messages.roleStaff)}
|
||||
{currentUserEmail === email && (
|
||||
<span className="badge-current-user">{intl.formatMessage(messages.roleYou)}</span>
|
||||
)}
|
||||
</Badge>
|
||||
<span className="member-info-name font-weight-bold">{userName}</span>
|
||||
<MailtoLink to={email}>{email}</MailtoLink>
|
||||
</div>
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{isAllowActions && (
|
||||
!isHideActions ? (
|
||||
<div className="member-actions">
|
||||
<Button
|
||||
variant={isAdminRole ? 'tertiary' : 'primary'}
|
||||
size="sm"
|
||||
onClick={() => onChangeRole(email, isAdminRole ? USER_ROLES.staff : USER_ROLES.admin)}
|
||||
>
|
||||
{isAdminRole ? intl.formatMessage(messages.removeButton) : intl.formatMessage(messages.addButton)}
|
||||
</Button>
|
||||
<Button
|
||||
className="delete-button"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
data-testid="delete-button"
|
||||
iconBefore={DeleteOutlineIcon}
|
||||
onClick={() => onDelete(email)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="member-hint text-right">
|
||||
<span>{intl.formatMessage(messages.hint)}</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CourseTeamMember.propTypes = {
|
||||
userName: PropTypes.string.isRequired,
|
||||
role: PropTypes.string.isRequired,
|
||||
email: PropTypes.string.isRequired,
|
||||
onChangeRole: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
currentUserEmail: PropTypes.string.isRequired,
|
||||
isHideActions: PropTypes.bool.isRequired,
|
||||
isAllowActions: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default CourseTeamMember;
|
||||
63
src/course-team/course-team-member/CourseTeamMember.scss
Normal file
63
src/course-team/course-team-member/CourseTeamMember.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
.course-team-container {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.course-team-member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
padding: 1.563rem 1.875rem 1.25rem;
|
||||
background-color: $white;
|
||||
border: .0625rem solid $gray-200;
|
||||
border-radius: .375rem;
|
||||
box-shadow: 0 1px 1px $gray-200;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -2.25rem;
|
||||
left: -.25rem;
|
||||
}
|
||||
|
||||
.badge-current-user {
|
||||
color: $gray-100;
|
||||
margin-left: .25rem;
|
||||
}
|
||||
|
||||
.member-info-name {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.member-hint {
|
||||
width: 45%;
|
||||
margin-right: 3.875rem;
|
||||
color: $gray-300;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
display: flex;
|
||||
gap: $spacer;
|
||||
|
||||
.delete-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
& > span {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
src/course-team/course-team-member/CourseTeamMember.test.jsx
Normal file
91
src/course-team/course-team-member/CourseTeamMember.test.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { USER_ROLES } from '../../constants';
|
||||
import CourseTeamMember from './CourseTeamMember';
|
||||
import messages from './messages';
|
||||
|
||||
const userNameMock = 'User';
|
||||
const emailMock = 'user@example.com';
|
||||
const currentUserEmailMock = 'user@example.com';
|
||||
const onChangeRoleMock = jest.fn();
|
||||
const onDeleteMock = jest.fn();
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<IntlProvider locale="en">
|
||||
<CourseTeamMember
|
||||
userName={userNameMock}
|
||||
email={emailMock}
|
||||
currentUserEmail="some@example.com"
|
||||
onChangeRole={onChangeRoleMock}
|
||||
onDelete={onDeleteMock}
|
||||
isAllowActions
|
||||
isHideActions={false}
|
||||
role={USER_ROLES.admin}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
describe('<CourseTeamMember />', () => {
|
||||
it('render CourseTeamMember component correctly', () => {
|
||||
const { getByText, getByRole, getByTestId } = renderComponent();
|
||||
|
||||
expect(getByText(userNameMock)).toBeInTheDocument();
|
||||
expect(getByText(emailMock)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.removeButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByTestId('delete-button')).toBeInTheDocument();
|
||||
expect(getByText(messages.roleAdmin.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays correct badge and "You" label for the current user', () => {
|
||||
const { getByText } = renderComponent({
|
||||
role: USER_ROLES.staff,
|
||||
currentUserEmail: currentUserEmailMock,
|
||||
});
|
||||
|
||||
expect(getByText(messages.roleStaff.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.roleYou.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('not displays actions when isAllowActions is false', () => {
|
||||
const { queryByRole, queryByTestId } = renderComponent({
|
||||
role: USER_ROLES.admin,
|
||||
currentUserEmail: currentUserEmailMock,
|
||||
isAllowActions: false,
|
||||
});
|
||||
|
||||
expect(queryByRole('button', { name: messages.removeButton.defaultMessage })).not.toBeInTheDocument();
|
||||
expect(queryByTestId('delete-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChangeRole when the "Add"/"Remove" button is clicked', () => {
|
||||
const { getByRole } = renderComponent({
|
||||
role: USER_ROLES.staff,
|
||||
});
|
||||
|
||||
const addButton = getByRole('button', { name: messages.addButton.defaultMessage });
|
||||
fireEvent.click(addButton);
|
||||
expect(onChangeRoleMock).toHaveBeenCalledTimes(1);
|
||||
expect(onChangeRoleMock).toHaveBeenCalledWith(emailMock, USER_ROLES.admin);
|
||||
});
|
||||
|
||||
it('calls onDelete when the "Delete" button is clicked', () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
const deleteButton = getByTestId('delete-button');
|
||||
fireEvent.click(deleteButton);
|
||||
expect(onDeleteMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders the "Hint" when isHideActions is true', () => {
|
||||
const { getByText, queryByRole, queryByTestId } = renderComponent({
|
||||
isHideActions: true,
|
||||
});
|
||||
|
||||
expect(queryByRole('button', { name: messages.addButton.defaultMessage })).not.toBeInTheDocument();
|
||||
expect(queryByTestId('delete-button')).not.toBeInTheDocument();
|
||||
expect(getByText(messages.hint.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
30
src/course-team/course-team-member/messages.js
Normal file
30
src/course-team/course-team-member/messages.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
roleAdmin: {
|
||||
id: 'course-authoring.course-team.member.role.admin',
|
||||
defaultMessage: 'Admin',
|
||||
},
|
||||
roleStaff: {
|
||||
id: 'course-authoring.course-team.member.role.staff',
|
||||
defaultMessage: 'Staff',
|
||||
},
|
||||
roleYou: {
|
||||
id: 'course-authoring.course-team.member.role.you',
|
||||
defaultMessage: 'You!',
|
||||
},
|
||||
hint: {
|
||||
id: 'course-authoring.course-team.member.hint',
|
||||
defaultMessage: 'Promote another member to Admin to remove your admin rights',
|
||||
},
|
||||
addButton: {
|
||||
id: 'course-authoring.course-team.member.button.add',
|
||||
defaultMessage: 'Add admin access',
|
||||
},
|
||||
removeButton: {
|
||||
id: 'course-authoring.course-team.member.button.remove',
|
||||
defaultMessage: 'Remove admin access',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,52 @@
|
||||
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 CourseTeamSidebar from './CourseTeamSidebar';
|
||||
import messages from './messages';
|
||||
import initializeStore from '../../store';
|
||||
|
||||
const courseId = 'course-123';
|
||||
let store;
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store} messages={{}}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseTeamSidebar courseId={courseId} {...props} />
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<CourseTeamSidebar />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it('render CourseTeamSidebar component correctly', () => {
|
||||
const { getByText } = renderComponent();
|
||||
|
||||
expect(getByText(messages.sidebarTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.sidebarAbout_1.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.sidebarAbout_2.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.sidebarAbout_3.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render CourseTeamSidebar when isOwnershipHint is true', () => {
|
||||
const { getByText } = renderComponent({ isOwnershipHint: true });
|
||||
|
||||
expect(getByText(messages.ownershipTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(
|
||||
'Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click to make another user the Admin, then ask that user to remove you from the Course Team list.',
|
||||
)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
68
src/course-team/course-team-sidebar/CourseTeamSidebar.jsx
Normal file
68
src/course-team/course-team-sidebar/CourseTeamSidebar.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import HelpSidebar from '../../generic/help-sidebar';
|
||||
import messages from './messages';
|
||||
|
||||
const CourseTeamSideBar = ({ courseId, isOwnershipHint, isShowInitialSidebar }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="course-team-sidebar"
|
||||
data-testid={isShowInitialSidebar ? 'course-team-sidebar__initial' : 'course-team-sidebar'}
|
||||
>
|
||||
<HelpSidebar
|
||||
intl={intl}
|
||||
courseId={courseId}
|
||||
showOtherSettings={false}
|
||||
>
|
||||
<h4 className="help-sidebar-about-title">
|
||||
{intl.formatMessage(messages.sidebarTitle)}
|
||||
</h4>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
{intl.formatMessage(messages.sidebarAbout_1)}
|
||||
</p>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
{intl.formatMessage(messages.sidebarAbout_2)}
|
||||
</p>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
{intl.formatMessage(messages.sidebarAbout_3)}
|
||||
</p>
|
||||
</HelpSidebar>
|
||||
{isOwnershipHint && (
|
||||
<>
|
||||
<hr />
|
||||
<HelpSidebar
|
||||
intl={intl}
|
||||
courseId={courseId}
|
||||
showOtherSettings={false}
|
||||
>
|
||||
<h4 className="help-sidebar-about-title">
|
||||
{intl.formatMessage(messages.ownershipTitle)}
|
||||
</h4>
|
||||
<p className="help-sidebar-about-descriptions">
|
||||
{intl.formatMessage(
|
||||
messages.ownershipDescription,
|
||||
{ strong: <strong>{intl.formatMessage(messages.addAdminAccess)}</strong> },
|
||||
)}
|
||||
</p>
|
||||
</HelpSidebar>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CourseTeamSideBar.defaultProps = {
|
||||
isShowInitialSidebar: false,
|
||||
};
|
||||
|
||||
CourseTeamSideBar.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
isOwnershipHint: PropTypes.bool.isRequired,
|
||||
isShowInitialSidebar: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default CourseTeamSideBar;
|
||||
11
src/course-team/course-team-sidebar/CourseTeamSidebar.scss
Normal file
11
src/course-team/course-team-sidebar/CourseTeamSidebar.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
.course-team-sidebar {
|
||||
.help-sidebar {
|
||||
&:not(:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/course-team/course-team-sidebar/messages.js
Normal file
34
src/course-team/course-team-sidebar/messages.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
sidebarTitle: {
|
||||
id: 'course-authoring.course-team.sidebar.title',
|
||||
defaultMessage: 'Course team roles',
|
||||
},
|
||||
sidebarAbout_1: {
|
||||
id: 'course-authoring.course-team.sidebar.about-1',
|
||||
defaultMessage: 'Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.',
|
||||
},
|
||||
sidebarAbout_2: {
|
||||
id: 'course-authoring.course-team.sidebar.about-2',
|
||||
defaultMessage: 'Admins are course team members who can add and remove other course team members.',
|
||||
},
|
||||
sidebarAbout_3: {
|
||||
id: 'course-authoring.course-team.sidebar.about-3',
|
||||
defaultMessage: 'All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.',
|
||||
},
|
||||
ownershipTitle: {
|
||||
id: 'course-authoring.course-team.sidebar.ownership.title',
|
||||
defaultMessage: 'Transferring ownership',
|
||||
},
|
||||
ownershipDescription: {
|
||||
id: 'course-authoring.course-team.sidebar.ownership.description',
|
||||
defaultMessage: 'Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.',
|
||||
},
|
||||
addAdminAccess: {
|
||||
id: 'course-authoring.course-team.sidebar.ownership.addAdminAccess',
|
||||
defaultMessage: 'Add admin access',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
54
src/course-team/data/api.js
Normal file
54
src/course-team/data/api.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { USER_ROLES } from '../../constants';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getCourseTeamApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_team/${courseId}`;
|
||||
export const updateCourseTeamUserApiUrl = (courseId, email) => `${getApiBaseUrl()}/course_team/${courseId}/${email}`;
|
||||
|
||||
/**
|
||||
* Get course team.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getCourseTeam(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseTeamApiUrl(courseId));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create course team user.
|
||||
* @param {string} courseId
|
||||
* @param {string} email
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function createTeamUser(courseId, email) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.post(updateCourseTeamUserApiUrl(courseId, email), { role: USER_ROLES.staff });
|
||||
}
|
||||
|
||||
/**
|
||||
* Change role course team user.
|
||||
* @param {string} courseId
|
||||
* @param {string} email
|
||||
* @param {string} role
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function changeRoleTeamUser(courseId, email, role) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.put(updateCourseTeamUserApiUrl(courseId, email), { role });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete course team user.
|
||||
* @param {string} courseId
|
||||
* @param {string} email
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function deleteTeamUser(courseId, email) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(updateCourseTeamUserApiUrl(courseId, email));
|
||||
}
|
||||
6
src/course-team/data/selectors.js
Normal file
6
src/course-team/data/selectors.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export const getCourseTeamUsers = (state) => state.courseTeam.users;
|
||||
export const getCourseTeamLoadingStatus = (state) => state.courseTeam.loadingCourseTeamStatus;
|
||||
export const getErrorMessage = (state) => state.courseTeam.errorMessage;
|
||||
export const getIsAllowActions = (state) => state.courseTeam.allowActions;
|
||||
export const getIsOwnershipHint = (state) => state.courseTeam.showTransferOwnershipHint;
|
||||
export const getSavingStatus = (state) => state.courseTeam.savingStatus;
|
||||
46
src/course-team/data/slice.js
Normal file
46
src/course-team/data/slice.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'courseTeam',
|
||||
initialState: {
|
||||
loadingCourseTeamStatus: RequestStatus.IN_PROGRESS,
|
||||
savingStatus: '',
|
||||
users: [],
|
||||
showTransferOwnershipHint: false,
|
||||
allowActions: false,
|
||||
errorMessage: '',
|
||||
},
|
||||
reducers: {
|
||||
fetchCourseTeamSuccess: (state, { payload }) => {
|
||||
state.users = payload.users;
|
||||
state.showTransferOwnershipHint = payload.showTransferOwnershipHint;
|
||||
state.allowActions = payload.allowActions;
|
||||
},
|
||||
updateLoadingCourseTeamStatus: (state, { payload }) => {
|
||||
state.loadingCourseTeamStatus = payload.status;
|
||||
},
|
||||
deleteCourseTeamUser: (state, { payload }) => {
|
||||
state.users = state.users.filter((user) => user.email !== payload);
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
setErrorMessage: (state, { payload }) => {
|
||||
state.errorMessage = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchCourseTeamSuccess,
|
||||
updateLoadingCourseTeamStatus,
|
||||
deleteCourseTeamUser,
|
||||
updateSavingStatus,
|
||||
setErrorMessage,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
87
src/course-team/data/thunk.js
Normal file
87
src/course-team/data/thunk.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import {
|
||||
getCourseTeam,
|
||||
deleteTeamUser,
|
||||
createTeamUser,
|
||||
changeRoleTeamUser,
|
||||
} from './api';
|
||||
import {
|
||||
fetchCourseTeamSuccess,
|
||||
updateLoadingCourseTeamStatus,
|
||||
deleteCourseTeamUser,
|
||||
updateSavingStatus,
|
||||
setErrorMessage,
|
||||
} from './slice';
|
||||
|
||||
export function fetchCourseTeamQuery(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const courseTeam = await getCourseTeam(courseId);
|
||||
dispatch(fetchCourseTeamSuccess(courseTeam));
|
||||
|
||||
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createCourseTeamQuery(courseId, email) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await createTeamUser(courseId, email);
|
||||
const courseTeam = await getCourseTeam(courseId);
|
||||
dispatch(fetchCourseTeamSuccess(courseTeam));
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.error || '';
|
||||
dispatch(setErrorMessage(message));
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function changeRoleTeamUserQuery(courseId, email, role) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await changeRoleTeamUser(courseId, email, role);
|
||||
const courseTeam = await getCourseTeam(courseId);
|
||||
dispatch(fetchCourseTeamSuccess(courseTeam));
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch ({ message }) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseTeamQuery(courseId, email) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await deleteTeamUser(courseId, email);
|
||||
dispatch(deleteCourseTeamUser(email));
|
||||
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
139
src/course-team/hooks.jsx
Normal file
139
src/course-team/hooks.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useToggle } from '@edx/paragon';
|
||||
|
||||
import { USER_ROLES } from '../constants';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { useModel } from '../generic/model-store';
|
||||
import {
|
||||
changeRoleTeamUserQuery,
|
||||
createCourseTeamQuery,
|
||||
deleteCourseTeamQuery,
|
||||
fetchCourseTeamQuery,
|
||||
} from './data/thunk';
|
||||
import {
|
||||
getCourseTeamLoadingStatus,
|
||||
getCourseTeamUsers,
|
||||
getErrorMessage,
|
||||
getIsAllowActions,
|
||||
getIsOwnershipHint, getSavingStatus,
|
||||
} from './data/selectors';
|
||||
import { setErrorMessage } from './data/slice';
|
||||
import { MODAL_TYPES } from './constants';
|
||||
|
||||
const useCourseTeam = ({ courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { email: currentUserEmail } = getAuthenticatedUser();
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
|
||||
const [modalType, setModalType] = useState(MODAL_TYPES.delete);
|
||||
const [isInfoModalOpen, openInfoModal, closeInfoModal] = useToggle(false);
|
||||
const [isFormVisible, openForm, hideForm] = useToggle(false);
|
||||
const [currentEmail, setCurrentEmail] = useState('');
|
||||
const [isQueryPending, setIsQueryPending] = useState(false);
|
||||
const courseTeamUsers = useSelector(getCourseTeamUsers);
|
||||
const errorMessage = useSelector(getErrorMessage);
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
const isAllowActions = useSelector(getIsAllowActions);
|
||||
const isOwnershipHint = useSelector(getIsOwnershipHint);
|
||||
const loadingCourseTeamStatus = useSelector(getCourseTeamLoadingStatus);
|
||||
|
||||
const isSingleAdmin = courseTeamUsers.filter((user) => user.role === USER_ROLES.admin).length === 1;
|
||||
|
||||
const handleOpenInfoModal = (type, email) => {
|
||||
setCurrentEmail(email);
|
||||
setModalType(type);
|
||||
openInfoModal();
|
||||
};
|
||||
|
||||
const handleCloseInfoModal = () => {
|
||||
dispatch(setErrorMessage(''));
|
||||
closeInfoModal();
|
||||
};
|
||||
|
||||
const handleAddUserSubmit = (data) => {
|
||||
setIsQueryPending(true);
|
||||
|
||||
const { email } = data;
|
||||
const isUserContains = courseTeamUsers.some((user) => user.email === email);
|
||||
|
||||
if (isUserContains) {
|
||||
handleOpenInfoModal(MODAL_TYPES.warning, email);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(createCourseTeamQuery(courseId, email)).then((result) => {
|
||||
if (result) {
|
||||
hideForm();
|
||||
dispatch(setErrorMessage(''));
|
||||
return;
|
||||
}
|
||||
|
||||
handleOpenInfoModal(MODAL_TYPES.error, email);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteUserSubmit = () => {
|
||||
setIsQueryPending(true);
|
||||
dispatch(deleteCourseTeamQuery(courseId, currentEmail));
|
||||
handleCloseInfoModal();
|
||||
};
|
||||
|
||||
const handleChangeRoleUserSubmit = (email, role) => {
|
||||
setIsQueryPending(true);
|
||||
dispatch(changeRoleTeamUserQuery(courseId, email, role));
|
||||
};
|
||||
|
||||
const handleInternetConnectionFailed = () => {
|
||||
setIsQueryPending(false);
|
||||
};
|
||||
|
||||
const handleOpenDeleteModal = (email) => {
|
||||
handleOpenInfoModal(MODAL_TYPES.delete, email);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseTeamQuery(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
setIsQueryPending(false);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
return {
|
||||
modalType,
|
||||
errorMessage,
|
||||
courseName: courseDetails?.name || '',
|
||||
currentEmail,
|
||||
courseTeamUsers,
|
||||
currentUserEmail,
|
||||
isLoading: loadingCourseTeamStatus === RequestStatus.IN_PROGRESS,
|
||||
isSingleAdmin,
|
||||
isFormVisible,
|
||||
isAllowActions,
|
||||
isInfoModalOpen,
|
||||
isOwnershipHint,
|
||||
isQueryPending,
|
||||
isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED,
|
||||
isShowAddTeamMember: courseTeamUsers.length === 1 && isAllowActions,
|
||||
isShowInitialSidebar: !courseTeamUsers.length && !isFormVisible,
|
||||
isShowUserFilledSidebar: Boolean(courseTeamUsers.length) || isFormVisible,
|
||||
openForm,
|
||||
hideForm,
|
||||
closeInfoModal,
|
||||
handleAddUserSubmit,
|
||||
handleOpenInfoModal,
|
||||
handleOpenDeleteModal,
|
||||
handleDeleteUserSubmit,
|
||||
handleChangeRoleUserSubmit,
|
||||
handleInternetConnectionFailed,
|
||||
};
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { useCourseTeam };
|
||||
75
src/course-team/info-modal/InfoModal.jsx
Normal file
75
src/course-team/info-modal/InfoModal.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
ActionRow,
|
||||
Button,
|
||||
AlertModal,
|
||||
} from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { MODAL_TYPES } from '../constants';
|
||||
import { getInfoModalSettings } from '../utils';
|
||||
|
||||
const InfoModal = ({
|
||||
modalType,
|
||||
isOpen,
|
||||
close,
|
||||
onDeleteSubmit,
|
||||
currentEmail,
|
||||
errorMessage,
|
||||
courseName,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
title,
|
||||
message,
|
||||
variant,
|
||||
closeButtonText,
|
||||
submitButtonText,
|
||||
closeButtonVariant,
|
||||
} = getInfoModalSettings(modalType, currentEmail, errorMessage, courseName, intl);
|
||||
|
||||
const isEmptyErrorMessage = modalType === MODAL_TYPES.error && !errorMessage;
|
||||
|
||||
return (
|
||||
<AlertModal
|
||||
title={title}
|
||||
variant={variant}
|
||||
isOpen={isOpen && !isEmptyErrorMessage}
|
||||
onClose={close}
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button variant={closeButtonVariant} onClick={close}>
|
||||
{closeButtonText}
|
||||
</Button>
|
||||
{modalType === MODAL_TYPES.delete && (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onDeleteSubmit();
|
||||
}}
|
||||
>
|
||||
{submitButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
<p>{message}</p>
|
||||
</AlertModal>
|
||||
);
|
||||
};
|
||||
|
||||
InfoModal.propTypes = {
|
||||
modalType: PropTypes.string.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
close: PropTypes.func.isRequired,
|
||||
currentEmail: PropTypes.string.isRequired,
|
||||
errorMessage: PropTypes.string.isRequired,
|
||||
courseName: PropTypes.string.isRequired,
|
||||
onDeleteSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default InfoModal;
|
||||
85
src/course-team/info-modal/InfoModal.test.jsx
Normal file
85
src/course-team/info-modal/InfoModal.test.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { MODAL_TYPES } from '../constants';
|
||||
import InfoModal from './InfoModal';
|
||||
import messages from './messages';
|
||||
|
||||
const closeMock = jest.fn();
|
||||
const onDeleteSubmitMock = jest.fn();
|
||||
const currentEmailMock = 'user@example.com';
|
||||
const errorMessageMock = 'Error text error@example.com';
|
||||
const courseNameMock = 'Course Name';
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<IntlProvider locale="en">
|
||||
<InfoModal
|
||||
modalType={MODAL_TYPES.delete}
|
||||
isOpen
|
||||
close={closeMock}
|
||||
onDeleteSubmit={onDeleteSubmitMock}
|
||||
currentEmail={currentEmailMock}
|
||||
errorMessage={errorMessageMock}
|
||||
courseName={courseNameMock}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
describe('<InfoModal />', () => {
|
||||
it('render InfoModal component with type delete correctly', () => {
|
||||
const { getByText, getByRole } = renderComponent({
|
||||
modalType: MODAL_TYPES.delete,
|
||||
});
|
||||
|
||||
expect(getByText(messages.deleteModalTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(
|
||||
messages.deleteModalMessage.defaultMessage
|
||||
.replace('{email}', currentEmailMock)
|
||||
.replace('{courseName}', courseNameMock),
|
||||
)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.deleteModalCancelButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.deleteModalDeleteButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render InfoModal component with type error correctly', () => {
|
||||
const { getByText, getByRole } = renderComponent({
|
||||
modalType: MODAL_TYPES.error,
|
||||
});
|
||||
|
||||
expect(getByText(messages.errorModalTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(errorMessageMock)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.errorModalOkButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render InfoModal component with type warning correctly', () => {
|
||||
const { getByText, getByRole } = renderComponent({
|
||||
modalType: MODAL_TYPES.warning,
|
||||
});
|
||||
|
||||
expect(getByText(messages.warningModalTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(
|
||||
messages.warningModalMessage.defaultMessage
|
||||
.replace('{email}', currentEmailMock)
|
||||
.replace('{courseName}', courseNameMock),
|
||||
)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.warningModalReturnButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls close handler when the close button is clicked', () => {
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const closeButton = getByRole('button', { name: messages.deleteModalCancelButton.defaultMessage });
|
||||
fireEvent.click(closeButton);
|
||||
expect(closeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onDeleteSubmit handler when the delete button is clicked', () => {
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const deleteButton = getByRole('button', { name: messages.deleteModalDeleteButton.defaultMessage });
|
||||
fireEvent.click(deleteButton);
|
||||
expect(onDeleteSubmitMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
42
src/course-team/info-modal/messages.js
Normal file
42
src/course-team/info-modal/messages.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteModalTitle: {
|
||||
id: 'course-authoring.course-team.member.button.remove',
|
||||
defaultMessage: 'Are you sure?',
|
||||
},
|
||||
deleteModalMessage: {
|
||||
id: 'course-authoring.course-team.delete-modal.message',
|
||||
defaultMessage: 'Are you sure you want to delete {email} from the course team for “{courseName}”?',
|
||||
},
|
||||
deleteModalDeleteButton: {
|
||||
id: 'course-authoring.course-team.delete-modal.button.delete',
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
deleteModalCancelButton: {
|
||||
id: 'course-authoring.course-team.delete-modal.button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
errorModalTitle: {
|
||||
id: 'course-authoring.course-team.error-modal.title',
|
||||
defaultMessage: 'Error adding user',
|
||||
},
|
||||
errorModalOkButton: {
|
||||
id: 'course-authoring.course-team.error-modal.button.ok',
|
||||
defaultMessage: 'Ok',
|
||||
},
|
||||
warningModalTitle: {
|
||||
id: 'course-authoring.course-team.warning-modal.title',
|
||||
defaultMessage: 'Already a course team member',
|
||||
},
|
||||
warningModalMessage: {
|
||||
id: 'course-authoring.course-team.warning-modal.message',
|
||||
defaultMessage: '{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.',
|
||||
},
|
||||
warningModalReturnButton: {
|
||||
id: 'course-authoring.course-team.warning-modal.button.return',
|
||||
defaultMessage: 'Return to team listing',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
18
src/course-team/messages.js
Normal file
18
src/course-team/messages.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
headingTitle: {
|
||||
id: 'course-authoring.course-team.headingTitle',
|
||||
defaultMessage: 'Course team',
|
||||
},
|
||||
headingSubtitle: {
|
||||
id: 'course-authoring.course-team.subTitle',
|
||||
defaultMessage: 'Settings',
|
||||
},
|
||||
addNewMemberButton: {
|
||||
id: 'course-authoring.course-team.button.new-team-member',
|
||||
defaultMessage: 'New team member',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
53
src/course-team/utils.js
Normal file
53
src/course-team/utils.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { MODAL_TYPES } from './constants';
|
||||
import messages from './info-modal/messages';
|
||||
|
||||
/**
|
||||
* Create an info modal settings dependent on modal type
|
||||
* @param {typeof MODAL_TYPES} modalType - one of MODAL_TYPES
|
||||
* @param {string} currentEmail - email in current user
|
||||
* @param {string} errorEmail - email from wrong request
|
||||
* @param {string} courseName - current course name
|
||||
* @returns {{
|
||||
* title: string,
|
||||
* message: string,
|
||||
* variant: string,
|
||||
* closeButtonText: string,
|
||||
* submitButtonText: string,
|
||||
* closeButtonVariant: string
|
||||
* }}
|
||||
*/
|
||||
|
||||
const getInfoModalSettings = (modalType, currentEmail, errorMessage, courseName, intl) => {
|
||||
switch (modalType) {
|
||||
case MODAL_TYPES.delete:
|
||||
return {
|
||||
title: intl.formatMessage(messages.deleteModalTitle),
|
||||
message: intl.formatMessage(messages.deleteModalMessage, { email: currentEmail, courseName }),
|
||||
variant: 'danger',
|
||||
closeButtonText: intl.formatMessage(messages.deleteModalCancelButton),
|
||||
submitButtonText: intl.formatMessage(messages.deleteModalDeleteButton),
|
||||
closeButtonVariant: 'tertiary',
|
||||
};
|
||||
case MODAL_TYPES.error:
|
||||
return {
|
||||
title: intl.formatMessage(messages.errorModalTitle),
|
||||
message: errorMessage,
|
||||
variant: 'danger',
|
||||
closeButtonText: intl.formatMessage(messages.errorModalOkButton),
|
||||
closeButtonVariant: 'danger',
|
||||
};
|
||||
case MODAL_TYPES.warning:
|
||||
return {
|
||||
title: intl.formatMessage(messages.warningModalTitle),
|
||||
message: intl.formatMessage(messages.warningModalMessage, { email: currentEmail, courseName }),
|
||||
variant: 'warning',
|
||||
closeButtonText: intl.formatMessage(messages.warningModalReturnButton),
|
||||
mainButtonVariant: 'primary',
|
||||
};
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { getInfoModalSettings };
|
||||
163
src/course-updates/CourseUpdates.jsx
Normal file
163
src/course-updates/CourseUpdates.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Layout,
|
||||
} from '@edx/paragon';
|
||||
import { Add as AddIcon } from '@edx/paragon/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
|
||||
import ProcessingNotification from '../generic/processing-notification';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import CourseHandouts from './course-handouts/CourseHandouts';
|
||||
import CourseUpdate from './course-update/CourseUpdate';
|
||||
import DeleteModal from './delete-modal/DeleteModal';
|
||||
import UpdateForm from './update-form/UpdateForm';
|
||||
import { REQUEST_TYPES } from './constants';
|
||||
import messages from './messages';
|
||||
import { useCourseUpdates } from './hooks';
|
||||
import { getLoadingStatuses, getSavingStatuses } from './data/selectors';
|
||||
import { matchesAnyStatus } from './utils';
|
||||
|
||||
const CourseUpdates = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
requestType,
|
||||
courseUpdates,
|
||||
courseHandouts,
|
||||
courseUpdatesInitialValues,
|
||||
isMainFormOpen,
|
||||
isInnerFormOpen,
|
||||
isUpdateFormOpen,
|
||||
isDeleteModalOpen,
|
||||
closeUpdateForm,
|
||||
closeDeleteModal,
|
||||
handleUpdatesSubmit,
|
||||
handleOpenUpdateForm,
|
||||
handleOpenDeleteForm,
|
||||
handleDeleteUpdateSubmit,
|
||||
} = useCourseUpdates({ courseId });
|
||||
|
||||
const {
|
||||
isShow: isShowProcessingNotification,
|
||||
title: processingNotificationTitle,
|
||||
} = useSelector(getProcessingNotification);
|
||||
|
||||
const loadingStatuses = useSelector(getLoadingStatuses);
|
||||
const savingStatuses = useSelector(getSavingStatuses);
|
||||
|
||||
const anyStatusFailed = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.FAILED);
|
||||
const anyStatusInProgress = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.IN_PROGRESS);
|
||||
const anyStatusPending = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.PENDING);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container size="xl" className="m-4">
|
||||
<section className="setting-items mb-4 mt-5">
|
||||
<Layout
|
||||
lg={[{ span: 12 }]}
|
||||
md={[{ span: 12 }]}
|
||||
sm={[{ span: 12 }]}
|
||||
xs={[{ span: 12 }]}
|
||||
xl={[{ span: 12 }]}
|
||||
>
|
||||
<Layout.Element>
|
||||
<article>
|
||||
<div>
|
||||
<SubHeader
|
||||
title={intl.formatMessage(messages.headingTitle)}
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
instruction={intl.formatMessage(messages.sectionInfo)}
|
||||
headerActions={(
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={AddIcon}
|
||||
size="sm"
|
||||
onClick={() => handleOpenUpdateForm(REQUEST_TYPES.add_new_update)}
|
||||
disabled={isUpdateFormOpen}
|
||||
>
|
||||
{intl.formatMessage(messages.newUpdateButton)}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<section className="updates-section">
|
||||
{isMainFormOpen && (
|
||||
<UpdateForm
|
||||
isOpen={isUpdateFormOpen}
|
||||
close={closeUpdateForm}
|
||||
requestType={requestType}
|
||||
onSubmit={handleUpdatesSubmit}
|
||||
courseUpdatesInitialValues={courseUpdatesInitialValues}
|
||||
/>
|
||||
)}
|
||||
<div className="updates-container">
|
||||
<div className="p-4.5">
|
||||
{courseUpdates.length ? courseUpdates.map((courseUpdate, index) => (
|
||||
isInnerFormOpen(courseUpdate.id) ? (
|
||||
<UpdateForm
|
||||
isOpen={isUpdateFormOpen}
|
||||
close={closeUpdateForm}
|
||||
requestType={requestType}
|
||||
isInnerForm
|
||||
isFirstUpdate={index === 0}
|
||||
onSubmit={handleUpdatesSubmit}
|
||||
courseUpdatesInitialValues={courseUpdatesInitialValues}
|
||||
/>
|
||||
) : (
|
||||
<CourseUpdate
|
||||
dateForUpdate={courseUpdate.date}
|
||||
contentForUpdate={courseUpdate.content}
|
||||
onEdit={() => handleOpenUpdateForm(REQUEST_TYPES.edit_update, courseUpdate)}
|
||||
onDelete={() => handleOpenDeleteForm(courseUpdate)}
|
||||
isDisabledButtons={isUpdateFormOpen}
|
||||
/>
|
||||
))) : null}
|
||||
</div>
|
||||
<div className="updates-handouts-container">
|
||||
<CourseHandouts
|
||||
contentForHandouts={courseHandouts?.data || ''}
|
||||
onEdit={() => handleOpenUpdateForm(REQUEST_TYPES.edit_handouts)}
|
||||
isDisabledButtons={isUpdateFormOpen}
|
||||
/>
|
||||
</div>
|
||||
<DeleteModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
close={closeDeleteModal}
|
||||
onDeleteSubmit={handleDeleteUpdateSubmit}
|
||||
/>
|
||||
{isShowProcessingNotification && (
|
||||
<ProcessingNotification
|
||||
isShow={isShowProcessingNotification}
|
||||
title={processingNotificationTitle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
</section>
|
||||
</Container>
|
||||
<div className="alert-toast">
|
||||
<InternetConnectionAlert
|
||||
isFailed={anyStatusFailed}
|
||||
isQueryPending={anyStatusInProgress || anyStatusPending}
|
||||
onInternetConnectionFailed={() => null}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CourseUpdates.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CourseUpdates;
|
||||
20
src/course-updates/CourseUpdates.scss
Normal file
20
src/course-updates/CourseUpdates.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
@import "./course-handouts/CourseHandouts";
|
||||
@import "./course-update/CourseUpdate";
|
||||
@import "./update-form/UpdateForm";
|
||||
|
||||
.updates-container {
|
||||
@include pgn-box-shadow(1, "centered");
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 65% 35%;
|
||||
border: .0625rem solid $gray-200;
|
||||
border-radius: .375rem;
|
||||
background: $white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.updates-handouts-container {
|
||||
border-left: .0625rem solid $gray-200;
|
||||
padding: 1.875rem;
|
||||
background: $white;
|
||||
}
|
||||
220
src/course-updates/CourseUpdates.test.jsx
Normal file
220
src/course-updates/CourseUpdates.test.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React from 'react';
|
||||
import { render, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import {
|
||||
getCourseUpdatesApiUrl,
|
||||
getCourseHandoutApiUrl,
|
||||
updateCourseUpdatesApiUrl,
|
||||
} from './data/api';
|
||||
import {
|
||||
createCourseUpdateQuery,
|
||||
deleteCourseUpdateQuery,
|
||||
editCourseHandoutsQuery,
|
||||
editCourseUpdateQuery,
|
||||
} from './data/thunk';
|
||||
import initializeStore from '../store';
|
||||
import { executeThunk } from '../utils';
|
||||
import { courseUpdatesMock, courseHandoutsMock } from './__mocks__';
|
||||
import CourseUpdates from './CourseUpdates';
|
||||
import messages from './messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
pathname: mockPathname,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@tinymce/tinymce-react', () => {
|
||||
const originalModule = jest.requireActual('@tinymce/tinymce-react');
|
||||
return {
|
||||
__esModule: true,
|
||||
...originalModule,
|
||||
Editor: () => 'foo bar',
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@edx/frontend-lib-content-components', () => ({
|
||||
TinyMceWidget: () => <div>Widget</div>,
|
||||
prepareEditorRef: jest.fn(() => ({
|
||||
refReady: true,
|
||||
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
|
||||
})),
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseUpdates courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<CourseUpdates />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(getCourseUpdatesApiUrl(courseId))
|
||||
.reply(200, courseUpdatesMock);
|
||||
axiosMock
|
||||
.onGet(getCourseHandoutApiUrl(courseId))
|
||||
.reply(200, courseHandoutsMock);
|
||||
});
|
||||
|
||||
it('render CourseUpdates component correctly', async () => {
|
||||
const {
|
||||
getByText, getAllByTestId, getByTestId, getByRole,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.sectionInfo.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getAllByTestId('course-update')).toHaveLength(3);
|
||||
expect(getByTestId('course-handouts')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should create course update', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
|
||||
const data = {
|
||||
content: '<p>Some text</p>',
|
||||
date: 'August 29, 2023',
|
||||
};
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseUpdatesApiUrl(courseId))
|
||||
.reply(200, data);
|
||||
|
||||
await executeThunk(createCourseUpdateQuery(courseId, data), store.dispatch);
|
||||
expect(getByText('Some text')).toBeInTheDocument();
|
||||
expect(getByText(data.date)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should edit course update', async () => {
|
||||
const { getByText, queryByText } = render(<RootWrapper />);
|
||||
|
||||
const data = {
|
||||
id: courseUpdatesMock[0].id,
|
||||
content: '<p>Some text</p>',
|
||||
date: 'August 29, 2023',
|
||||
};
|
||||
|
||||
axiosMock
|
||||
.onPut(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
|
||||
.reply(200, data);
|
||||
|
||||
await executeThunk(editCourseUpdateQuery(courseId, data), store.dispatch);
|
||||
expect(getByText('Some text')).toBeInTheDocument();
|
||||
expect(getByText(data.date)).toBeInTheDocument();
|
||||
expect(queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument();
|
||||
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should delete course update', async () => {
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onDelete(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
|
||||
.reply(200);
|
||||
|
||||
await executeThunk(deleteCourseUpdateQuery(courseId, courseUpdatesMock[0].id), store.dispatch);
|
||||
expect(queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument();
|
||||
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should edit course handouts', async () => {
|
||||
const { getByText, queryByText } = render(<RootWrapper />);
|
||||
|
||||
const data = {
|
||||
...courseHandoutsMock,
|
||||
data: '<p>Some handouts 1</p>',
|
||||
};
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseHandoutApiUrl(courseId))
|
||||
.reply(200, data);
|
||||
|
||||
await executeThunk(editCourseHandoutsQuery(courseId, data), store.dispatch);
|
||||
expect(getByText('Some handouts 1')).toBeInTheDocument();
|
||||
expect(queryByText(courseHandoutsMock.data)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Add new update form is visible after clicking "New update" button', async () => {
|
||||
const { getByText, getByRole, getAllByRole } = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editButtons = getAllByRole('button', { name: 'Edit' });
|
||||
const deleteButtons = getAllByRole('button', { name: 'Delete' });
|
||||
const newUpdateButton = getByRole('button', { name: messages.newUpdateButton.defaultMessage });
|
||||
|
||||
fireEvent.click(newUpdateButton);
|
||||
|
||||
expect(newUpdateButton).toBeDisabled();
|
||||
editButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
deleteButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
expect(getByText('Add new update')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Edit handouts form is visible after clicking "Edit" button', async () => {
|
||||
const {
|
||||
getByText, getByRole, getByTestId, getAllByRole,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editHandoutsButton = getByTestId('course-handouts-edit-button');
|
||||
const editButtons = getAllByRole('button', { name: 'Edit' });
|
||||
const deleteButtons = getAllByRole('button', { name: 'Delete' });
|
||||
|
||||
fireEvent.click(editHandoutsButton);
|
||||
|
||||
expect(editHandoutsButton).toBeDisabled();
|
||||
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
|
||||
editButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
deleteButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
expect(getByText('Edit handouts')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Edit update form is visible after clicking "Edit" button', async () => {
|
||||
const {
|
||||
getByText, getByRole, getAllByTestId, getAllByRole, queryByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
const editUpdateFirstButton = getAllByTestId('course-update-edit-button')[0];
|
||||
const editButtons = getAllByRole('button', { name: 'Edit' });
|
||||
const deleteButtons = getAllByRole('button', { name: 'Delete' });
|
||||
|
||||
fireEvent.click(editUpdateFirstButton);
|
||||
expect(getByText('Edit update')).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
|
||||
editButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
deleteButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
83
src/course-updates/__mocks__/courseHandouts.js
Normal file
83
src/course-updates/__mocks__/courseHandouts.js
Normal file
@@ -0,0 +1,83 @@
|
||||
module.exports = {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@course_info+block@handouts',
|
||||
display_name: 'Text',
|
||||
category: 'course_info',
|
||||
has_children: false,
|
||||
edited_on: 'Jul 12, 2023 at 17:52 UTC',
|
||||
published: true,
|
||||
published_on: 'Jul 12, 2023 at 17:52 UTC',
|
||||
studio_url: null,
|
||||
released_to_students: false,
|
||||
release_date: null,
|
||||
visibility_state: 'unscheduled',
|
||||
has_explicit_staff_lock: false,
|
||||
start: '2030-01-01T00:00:00Z',
|
||||
graded: false,
|
||||
due_date: '',
|
||||
due: null,
|
||||
relative_weeks_due: null,
|
||||
format: null,
|
||||
course_graders: [
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
has_changes: null,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
explanatory_message: null,
|
||||
group_access: {},
|
||||
user_partitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
show_correctness: 'always',
|
||||
data: 'Some handouts',
|
||||
metadata: {},
|
||||
ancestor_has_staff_lock: false,
|
||||
user_partition_info: {
|
||||
selectable_partitions: [
|
||||
{
|
||||
id: 50,
|
||||
name: 'Enrollment Track Groups',
|
||||
scheme: 'enrollment_track',
|
||||
groups: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Verified Certificate',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Audit',
|
||||
selected: false,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selected_partition_index: -1,
|
||||
selected_groups_label: '',
|
||||
},
|
||||
};
|
||||
5
src/course-updates/__mocks__/courseUpdates.js
Normal file
5
src/course-updates/__mocks__/courseUpdates.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = [
|
||||
{ id: 1, date: 'July 11, 2023', content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.' },
|
||||
{ id: 2, date: 'August 20, 2023', content: 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.' },
|
||||
{ id: 3, date: 'January 30, 2023', content: 'But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects, dislikes, or avoids pleasure itself' },
|
||||
];
|
||||
2
src/course-updates/__mocks__/index.js
Normal file
2
src/course-updates/__mocks__/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as courseUpdatesMock } from './courseUpdates';
|
||||
export { default as courseHandoutsMock } from './courseHandouts';
|
||||
7
src/course-updates/constants.js
Normal file
7
src/course-updates/constants.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const REQUEST_TYPES = {
|
||||
add_new_update: 'add_new_update',
|
||||
edit_update: 'edit_update',
|
||||
edit_handouts: 'edit_handouts',
|
||||
delete_update: 'delete_update',
|
||||
};
|
||||
41
src/course-updates/course-handouts/CourseHandouts.jsx
Normal file
41
src/course-updates/course-handouts/CourseHandouts.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const CourseHandouts = ({ contentForHandouts, onEdit, isDisabledButtons }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="course-handouts" data-testid="course-handouts">
|
||||
<div className="course-handouts-header">
|
||||
<h2 className="course-handouts-header__title lead">{intl.formatMessage(messages.handoutsTitle)}</h2>
|
||||
<Button
|
||||
className="course-handouts-header__btn"
|
||||
data-testid="course-handouts-edit-button"
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
disabled={isDisabledButtons}
|
||||
>
|
||||
{intl.formatMessage(messages.editButton)}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="small"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: contentForHandouts || '' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CourseHandouts.propTypes = {
|
||||
contentForHandouts: PropTypes.string.isRequired,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
isDisabledButtons: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default CourseHandouts;
|
||||
16
src/course-updates/course-handouts/CourseHandouts.scss
Normal file
16
src/course-updates/course-handouts/CourseHandouts.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
.course-handouts {
|
||||
.course-handouts-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: $spacer;
|
||||
|
||||
.course-handouts-header__title {
|
||||
font-weight: 300;
|
||||
color: $gray-800;
|
||||
}
|
||||
|
||||
.course-handouts-header__btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/course-updates/course-handouts/CourseHandouts.test.jsx
Normal file
45
src/course-updates/course-handouts/CourseHandouts.test.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import CourseHandouts from './CourseHandouts';
|
||||
import messages from './messages';
|
||||
|
||||
const onEditMock = jest.fn();
|
||||
const handoutsContentMock = 'Handouts Content';
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<IntlProvider locale="en">
|
||||
<CourseHandouts
|
||||
onEdit={onEditMock}
|
||||
contentForHandouts={handoutsContentMock}
|
||||
isDisabledButtons={false}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
describe('<CourseHandouts />', () => {
|
||||
it('render CourseHandouts component correctly', () => {
|
||||
const { getByText, getByRole } = renderComponent();
|
||||
|
||||
expect(getByText(messages.handoutsTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(handoutsContentMock)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls the onEdit function when the edit button is clicked', () => {
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
|
||||
fireEvent.click(editButton);
|
||||
expect(onEditMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('"Edit" button is disabled when isDisabledButtons is true', () => {
|
||||
const { getByRole } = renderComponent({ isDisabledButtons: true });
|
||||
|
||||
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
|
||||
expect(editButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
14
src/course-updates/course-handouts/messages.js
Normal file
14
src/course-updates/course-handouts/messages.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
handoutsTitle: {
|
||||
id: 'course-authoring.course-updates.handouts.title',
|
||||
defaultMessage: 'Course handouts',
|
||||
},
|
||||
editButton: {
|
||||
id: 'course-authoring.course-updates.actions.edit',
|
||||
defaultMessage: 'Edit',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
64
src/course-updates/course-update/CourseUpdate.jsx
Normal file
64
src/course-updates/course-update/CourseUpdate.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Icon } from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Error as ErrorIcon } from '@edx/paragon/icons/es5';
|
||||
|
||||
import { isDateForUpdateValid } from './utils';
|
||||
import messages from './messages';
|
||||
|
||||
const CourseUpdate = ({
|
||||
dateForUpdate,
|
||||
contentForUpdate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isDisabledButtons,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="course-update" data-testid="course-update">
|
||||
<div className="course-update-header">
|
||||
<span className="course-update-header__date small font-weight-bold">{dateForUpdate}</span>
|
||||
{!isDateForUpdateValid(dateForUpdate) && (
|
||||
<div className="course-update-header__error">
|
||||
<Icon src={ErrorIcon} alt={intl.formatMessage(messages.errorMessage)} />
|
||||
<p className="message-error small m-0">{intl.formatMessage(messages.errorMessage)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="course-update-header__action">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
disabled={isDisabledButtons}
|
||||
data-testid="course-update-edit-button"
|
||||
>
|
||||
{intl.formatMessage(messages.editButton)}
|
||||
</Button>
|
||||
<Button variant="outline-primary" size="sm" onClick={onDelete} disabled={isDisabledButtons}>
|
||||
{intl.formatMessage(messages.deleteButton)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{Boolean(contentForUpdate) && (
|
||||
<div
|
||||
className="small text-gray-800"
|
||||
data-testid="course-update-content"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: contentForUpdate }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CourseUpdate.propTypes = {
|
||||
dateForUpdate: PropTypes.string.isRequired,
|
||||
contentForUpdate: PropTypes.string.isRequired,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
isDisabledButtons: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default CourseUpdate;
|
||||
36
src/course-updates/course-update/CourseUpdate.scss
Normal file
36
src/course-updates/course-update/CourseUpdate.scss
Normal file
@@ -0,0 +1,36 @@
|
||||
.course-update {
|
||||
&:not(:first-child) {
|
||||
padding-top: 1.875rem;
|
||||
margin-top: 1.875rem;
|
||||
border-top: 1px solid $light-400;
|
||||
}
|
||||
|
||||
.course-update-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1.125rem;
|
||||
gap: .5rem;
|
||||
|
||||
.course-update-header__date {
|
||||
line-height: 1.875rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.course-update-header__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .25rem;
|
||||
|
||||
svg {
|
||||
color: $warning-300;
|
||||
}
|
||||
}
|
||||
|
||||
.course-update-header__action {
|
||||
display: flex;
|
||||
width: auto;
|
||||
margin-left: auto;
|
||||
gap: .5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src/course-updates/course-update/CourseUpdate.test.jsx
Normal file
72
src/course-updates/course-update/CourseUpdate.test.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import CourseUpdate from './CourseUpdate';
|
||||
import messages from './messages';
|
||||
|
||||
const onEditMock = jest.fn();
|
||||
const onDeleteMock = jest.fn();
|
||||
const dateForUpdateMock = 'May 1, 2023';
|
||||
const contentForUpdateMock = 'Update Content';
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<IntlProvider locale="en">
|
||||
<CourseUpdate
|
||||
dateForUpdate={dateForUpdateMock}
|
||||
contentForUpdate={contentForUpdateMock}
|
||||
onEdit={onEditMock}
|
||||
onDelete={onDeleteMock}
|
||||
isDisabledButtons={false}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
describe('<CourseUpdate />', () => {
|
||||
it('render CourseUpdate component correctly', () => {
|
||||
const { getByText, getByRole } = renderComponent();
|
||||
|
||||
expect(getByText(dateForUpdateMock)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render CourseUpdate component without content correctly', () => {
|
||||
const { getByText, queryByTestId, getByRole } = renderComponent({ contentForUpdate: '' });
|
||||
|
||||
expect(getByText(dateForUpdateMock)).toBeInTheDocument();
|
||||
expect(queryByTestId('course-update-content')).not.toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render error message when dateForUpdate is inValid', () => {
|
||||
const { getByText } = renderComponent({ dateForUpdate: 'Welcome' });
|
||||
|
||||
expect(getByText(messages.errorMessage.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls the onEdit function when the "Edit" button is clicked', () => {
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
|
||||
fireEvent.click(editButton);
|
||||
expect(onEditMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls the onDelete function when the "Delete" button is clicked', () => {
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const deleteButton = getByRole('button', { name: messages.deleteButton.defaultMessage });
|
||||
fireEvent.click(deleteButton);
|
||||
expect(onDeleteMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('"Edit" and "Delete" buttons is disabled when isDisabledButtons is true', () => {
|
||||
const { getByRole } = renderComponent({ isDisabledButtons: true });
|
||||
|
||||
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeDisabled();
|
||||
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
18
src/course-updates/course-update/messages.js
Normal file
18
src/course-updates/course-update/messages.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
editButton: {
|
||||
id: 'course-authoring.course-updates.button.edit',
|
||||
defaultMessage: 'Edit',
|
||||
},
|
||||
deleteButton: {
|
||||
id: 'course-authoring.course-updates.button.delete',
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
errorMessage: {
|
||||
id: 'course-authoring.course-updates.date-invalid',
|
||||
defaultMessage: 'Action required: Enter a valid date.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user