Compare commits
84 Commits
open-relea
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7424b60a90 | ||
|
|
1c0e6fd4b5 | ||
|
|
bc3faa4105 | ||
|
|
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 |
1
.env
1
.env
@@ -27,4 +27,5 @@ USER_INFO_COOKIE_NAME=''
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
|
||||
@@ -29,4 +29,5 @@ USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
|
||||
@@ -28,4 +28,5 @@ USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS=false
|
||||
ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_NEW_EDITOR_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
|
||||
@@ -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, {
|
||||
@@ -9,5 +11,7 @@ module.exports = createConfig('eslint',
|
||||
'template-curly-spacing': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
indent: 'off',
|
||||
'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
|
||||
|
||||
6
Makefile
Executable file → Normal file
6
Makefile
Executable file → Normal file
@@ -1,12 +1,10 @@
|
||||
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"
|
||||
|
||||
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
|
||||
@@ -47,7 +45,7 @@ push_translations:
|
||||
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -f --mode reviewed --languages=$(transifex_langs)
|
||||
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
|
||||
162
README.rst
162
README.rst
@@ -1,17 +1,158 @@
|
||||
|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.
|
||||
|
||||
Prerequisite
|
||||
************
|
||||
Introduction
|
||||
************
|
||||
|
||||
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
|
||||
|
||||
Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below.
|
||||
|
||||
********
|
||||
Features
|
||||
********
|
||||
|
||||
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.
|
||||
|
||||
Installation and Startup
|
||||
------------------------
|
||||
========================
|
||||
|
||||
1. Clone the repo:
|
||||
|
||||
@@ -32,8 +173,21 @@ If your devstack includes the default Demo course, you can visit the following U
|
||||
- `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``.
|
||||
|
||||
|
||||
32915
package-lock.json
generated
32915
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -34,11 +34,10 @@
|
||||
},
|
||||
"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-lib-content-components": "^1.131.0",
|
||||
"@edx/frontend-platform": "2.5.1",
|
||||
"@edx/paragon": "20.6.1",
|
||||
"@edx/paragon": "^20.38.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.28",
|
||||
"@fortawesome/free-brands-svg-icons": "5.11.2",
|
||||
"@fortawesome/free-regular-svg-icons": "5.11.2",
|
||||
@@ -67,7 +66,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.0.0",
|
||||
"@edx/frontend-build": "^11.0.0",
|
||||
"@edx/frontend-build": "12.8.38",
|
||||
"@edx/reactifex": "^1.0.3",
|
||||
"@testing-library/jest-dom": "5.16.4",
|
||||
"@testing-library/react": "12.1.1",
|
||||
|
||||
@@ -14,7 +14,36 @@ import { getCourseAppsApiStatus, getLoadingStatus } from './pages-and-resources/
|
||||
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 />
|
||||
</div>
|
||||
);
|
||||
|
||||
const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,33 +64,26 @@ export default function CourseAuthoringPage({ courseId, children }) {
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
|
||||
{/* While V2 Editors are tempoarily served from thier own pages
|
||||
using url pattern containing /editor/,
|
||||
we shouldn't have the header and footer on these pages.
|
||||
This functionality will be removed in TNL-9591 */}
|
||||
{inProgress ? !pathname.includes('/editor/') && <Loading /> : <AppHeader />}
|
||||
{inProgress ? !pathname.includes('/editor/') && <Loading />
|
||||
: (
|
||||
<AppHeader
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{!inProgress && <AppFooter />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
CourseAuthoringPage.propTypes = {
|
||||
children: PropTypes.node,
|
||||
@@ -71,3 +93,5 @@ CourseAuthoringPage.propTypes = {
|
||||
CourseAuthoringPage.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
export default CourseAuthoringPage;
|
||||
|
||||
@@ -23,7 +23,7 @@ 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}>
|
||||
@@ -45,8 +45,10 @@ export default function CourseAuthoringRoutes({ courseId }) {
|
||||
</Switch>
|
||||
</CourseAuthoringPage>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
CourseAuthoringRoutes.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CourseAuthoringRoutes;
|
||||
|
||||
@@ -56,7 +56,7 @@ const CollapsableEditor = ({
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Body className="collapsible-body rounded px-0">{children}</Collapsible.Body>
|
||||
</Collapsible.Advanced>
|
||||
);
|
||||
);
|
||||
|
||||
CollapsableEditor.propTypes = {
|
||||
open: PropTypes.bool,
|
||||
|
||||
@@ -2,11 +2,11 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Card } from '@edx/paragon';
|
||||
|
||||
const DeletePopup = ({
|
||||
const ConfirmationPopup = ({
|
||||
label,
|
||||
bodyText,
|
||||
onDelete,
|
||||
deleteLabel,
|
||||
onConfirm,
|
||||
confirmLabel,
|
||||
onCancel,
|
||||
cancelLabel,
|
||||
}) => (
|
||||
@@ -22,21 +22,21 @@ const DeletePopup = ({
|
||||
<Button variant="tertiary" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant="outline-brand" className="ml-2" onClick={onDelete}>
|
||||
{deleteLabel}
|
||||
<Button variant="outline-brand" className="ml-2" onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
);
|
||||
|
||||
DeletePopup.propTypes = {
|
||||
ConfirmationPopup.propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
bodyText: PropTypes.string.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onConfirm: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
deleteLabel: PropTypes.string.isRequired,
|
||||
confirmLabel: PropTypes.string.isRequired,
|
||||
cancelLabel: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default DeletePopup;
|
||||
export default ConfirmationPopup;
|
||||
@@ -5,23 +5,21 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function ConnectionErrorAlert({ intl }) {
|
||||
return (
|
||||
<Alert variant="danger" data-testid="connectionErrorAlert">
|
||||
<FormattedMessage
|
||||
id="authoring.alert.error.connection"
|
||||
defaultMessage="We encountered a technical error when loading this page. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {supportLink} for help."
|
||||
values={{
|
||||
const ConnectionErrorAlert = ({ intl }) => (
|
||||
<Alert variant="danger" data-testid="connectionErrorAlert">
|
||||
<FormattedMessage
|
||||
id="authoring.alert.error.connection"
|
||||
defaultMessage="We encountered a technical error when loading this page. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {supportLink} for help."
|
||||
values={{
|
||||
supportLink: (
|
||||
<Alert.Link href={getConfig().SUPPORT_URL}>
|
||||
{intl.formatMessage(messages.supportText)}
|
||||
</Alert.Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
ConnectionErrorAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -2,38 +2,36 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Form, TransitionReplace } from '@edx/paragon';
|
||||
|
||||
function FieldFeedback({
|
||||
const FieldFeedback = ({
|
||||
feedbackClasses,
|
||||
transitionClasses,
|
||||
errorCondition,
|
||||
feedbackCondition,
|
||||
feedbackMessage,
|
||||
errorMessage,
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<TransitionReplace className={transitionClasses}>
|
||||
{feedbackCondition ? (
|
||||
<React.Fragment key="open1">
|
||||
<Form.Control.Feedback type="default" hasIcon={false} key={`${feedbackMessage}-feedback`}>
|
||||
<div className={`small ${feedbackClasses}`}>{feedbackMessage}</div>
|
||||
</Form.Control.Feedback>
|
||||
</React.Fragment>
|
||||
}) => (
|
||||
<>
|
||||
<TransitionReplace className={transitionClasses}>
|
||||
{feedbackCondition ? (
|
||||
<React.Fragment key="open1">
|
||||
<Form.Control.Feedback type="default" hasIcon={false} key={`${feedbackMessage}-feedback`}>
|
||||
<div className={`small ${feedbackClasses}`}>{feedbackMessage}</div>
|
||||
</Form.Control.Feedback>
|
||||
</React.Fragment>
|
||||
) : <React.Fragment key="close1" />}
|
||||
</TransitionReplace>
|
||||
</TransitionReplace>
|
||||
|
||||
<TransitionReplace>
|
||||
{errorCondition ? (
|
||||
<React.Fragment key="open">
|
||||
<Form.Control.Feedback type="invalid" hasIcon={false} key={`${errorMessage}-feedback`}>
|
||||
<div className={`small ${feedbackClasses}`}>{errorMessage}</div>
|
||||
</Form.Control.Feedback>
|
||||
</React.Fragment>
|
||||
<TransitionReplace>
|
||||
{errorCondition ? (
|
||||
<React.Fragment key="open">
|
||||
<Form.Control.Feedback type="invalid" hasIcon={false} key={`${errorMessage}-feedback`}>
|
||||
<div className={`small ${feedbackClasses}`}>{errorMessage}</div>
|
||||
</Form.Control.Feedback>
|
||||
</React.Fragment>
|
||||
) : <React.Fragment key="close" />}
|
||||
</TransitionReplace>
|
||||
</>
|
||||
</TransitionReplace>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
FieldFeedback.propTypes = {
|
||||
errorCondition: PropTypes.bool.isRequired,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Form, SwitchControl } from '@edx/paragon';
|
||||
|
||||
import './FormSwitchGroup.scss';
|
||||
|
||||
export default function FormSwitchGroup({
|
||||
const FormSwitchGroup = ({
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
@@ -13,7 +13,8 @@ export default function FormSwitchGroup({
|
||||
onChange,
|
||||
onBlur,
|
||||
checked,
|
||||
}) {
|
||||
disabled,
|
||||
}) => {
|
||||
const helpTextId = `${id}HelpText`;
|
||||
|
||||
// Note that we use controlId here _and_ set some IDs and aria-describedby attributes manually.
|
||||
@@ -36,6 +37,7 @@ export default function FormSwitchGroup({
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Form.Text
|
||||
@@ -47,7 +49,7 @@ export default function FormSwitchGroup({
|
||||
</div>
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
};
|
||||
FormSwitchGroup.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.node.isRequired,
|
||||
@@ -57,9 +59,13 @@ FormSwitchGroup.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onBlur: PropTypes.func,
|
||||
checked: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
FormSwitchGroup.defaultProps = {
|
||||
className: null,
|
||||
onBlur: null,
|
||||
name: null,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default FormSwitchGroup;
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/* eslint-disable react/jsx-no-useless-fragment */
|
||||
import { Form } from '@edx/paragon';
|
||||
import { getIn, useFormikContext } from 'formik';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import FormikErrorFeedback from './FormikErrorFeedback';
|
||||
|
||||
function FormikControl({
|
||||
const FormikControl = ({
|
||||
name,
|
||||
label,
|
||||
help,
|
||||
className,
|
||||
...params
|
||||
}) {
|
||||
}) => {
|
||||
const {
|
||||
touched, errors, handleChange, handleBlur, setFieldError,
|
||||
} = useFormikContext();
|
||||
@@ -35,7 +36,7 @@ function FormikControl({
|
||||
</FormikErrorFeedback>
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
FormikControl.propTypes = {
|
||||
name: PropTypes.element.isRequired,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getIn, useFormikContext } from 'formik';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
function FormikErrorFeedback({ name, children }) {
|
||||
const FormikErrorFeedback = ({ name, children }) => {
|
||||
const { touched, errors } = useFormikContext();
|
||||
const fieldTouched = getIn(touched, name);
|
||||
const fieldError = getIn(errors, name);
|
||||
@@ -23,7 +23,7 @@ function FormikErrorFeedback({ name, children }) {
|
||||
)}
|
||||
</TransitionReplace>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
FormikErrorFeedback.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
|
||||
@@ -2,28 +2,28 @@ import React from 'react';
|
||||
import { Spinner } from '@edx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div
|
||||
className="d-flex justify-content-center align-items-center flex-column"
|
||||
style={{
|
||||
const Loading = () => (
|
||||
<div
|
||||
className="d-flex justify-content-center align-items-center flex-column"
|
||||
style={{
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<Spinner
|
||||
animation="border"
|
||||
role="status"
|
||||
variant="primary"
|
||||
screenReaderText={(
|
||||
<span className="sr-only">
|
||||
<FormattedMessage
|
||||
id="authoring.loading"
|
||||
defaultMessage="Loading..."
|
||||
description="Screen-reader message for when a page is loading."
|
||||
/>
|
||||
</span>
|
||||
>
|
||||
<Spinner
|
||||
animation="border"
|
||||
role="status"
|
||||
variant="primary"
|
||||
screenReaderText={(
|
||||
<span className="sr-only">
|
||||
<FormattedMessage
|
||||
id="authoring.loading"
|
||||
defaultMessage="Loading..."
|
||||
description="Screen-reader message for when a page is loading."
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Loading;
|
||||
|
||||
@@ -2,15 +2,13 @@ import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
|
||||
function PermissionDeniedAlert() {
|
||||
return (
|
||||
<Alert variant="danger" data-testid="permissionDeniedAlert">
|
||||
<FormattedMessage
|
||||
id="authoring.alert.error.permission"
|
||||
defaultMessage="You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access."
|
||||
/>
|
||||
</Alert>
|
||||
const PermissionDeniedAlert = () => (
|
||||
<Alert variant="danger" data-testid="permissionDeniedAlert">
|
||||
<FormattedMessage
|
||||
id="authoring.alert.error.permission"
|
||||
defaultMessage="You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access."
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionDeniedAlert;
|
||||
|
||||
@@ -5,23 +5,21 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function SaveFormConnectionErrorAlert({ intl }) {
|
||||
return (
|
||||
<Alert variant="danger" data-testid="connectionErrorAlert">
|
||||
<FormattedMessage
|
||||
id="authoring.alert.save.error.connection"
|
||||
defaultMessage="We encountered a technical error when applying changes. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {supportLink} for help."
|
||||
values={{
|
||||
const SaveFormConnectionErrorAlert = ({ intl }) => (
|
||||
<Alert variant="danger" data-testid="connectionErrorAlert">
|
||||
<FormattedMessage
|
||||
id="authoring.alert.save.error.connection"
|
||||
defaultMessage="We encountered a technical error when applying changes. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {supportLink} for help."
|
||||
values={{
|
||||
supportLink: (
|
||||
<Alert.Link href={getConfig().SUPPORT_URL}>
|
||||
{intl.formatMessage(messages.supportText)}
|
||||
</Alert.Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
SaveFormConnectionErrorAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"authoring.loading": "التحميل جارٍ...",
|
||||
"authoring.alert.error.permission": "لست مخوّلا بمشاهدة هذه الصفحة. إن كنت ترى أن لديك حقًا في ذلك، فيرجى التواصل مع مدير فريق مساقك ليمنحك حق الوصول.",
|
||||
"authoring.alert.save.error.connection": "واجهنا خطأ تقنيًا أثناء تطبيق التغييرات. قد تكون هذه مشكلة عارضة، لذا يرجى المحاولة مجددًا خلال بضع دقائق. إن استمرت المشكلة فيرجى الذهاب إلى {support_link} للحصول على المساعدة.",
|
||||
"course-authoring.page.title": "Course Authoring | {siteName}",
|
||||
"authoring.alert.support.text": "صفحة الدعم",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.cancel": "إلغاء",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.save": "حفظ",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"authoring.loading": "Loading...",
|
||||
"authoring.alert.error.permission": "You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.",
|
||||
"authoring.alert.save.error.connection": "We encountered a technical error when applying changes. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {supportLink} for help.",
|
||||
"course-authoring.page.title": "Course Authoring | {siteName}",
|
||||
"authoring.alert.support.text": "Support Page",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.cancel": "Cancel",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.save": "Save",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"authoring.loading": "Cargando...",
|
||||
"authoring.alert.error.permission": "No te encuentras autorizado para ingresar a esta página. Si crees que deberías tener acceso, por favor contacta al equipo administrativo del curso para solicitar acceso.",
|
||||
"authoring.alert.save.error.connection": "Hemos detectado un error técnico al cargar esta página. Esto puede ser un problema temporal, así que por favor intente nuevamente en unos minutos. Si el problema persiste, por favor solicite ayuda en el siguiente enlace {supportLink}",
|
||||
"course-authoring.page.title": "Course Authoring | {siteName}",
|
||||
"authoring.alert.support.text": "Página de soporte",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.cancel": "Cancelar",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.save": "Guardar",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"authoring.loading": "Chargement...",
|
||||
"authoring.alert.error.permission": "Vous n'êtes pas autorisé à afficher cette page. Si vous croyez que vous devriez avoir accès à cette page, veuillez contacter l'équipe administrative du cours pour obtenir la permission.",
|
||||
"authoring.alert.save.error.connection": "Nous avons rencontré une erreur technique lors de l'application des modifications. Cela peut être un problème temporaire, veuillez donc réessayer dans quelques minutes. Si le problème persiste, accédez à {support_link} pour obtenir de l'aide.",
|
||||
"course-authoring.page.title": "Course Authoring | {siteName}",
|
||||
"authoring.alert.support.text": "Page de support",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.cancel": "Annuler",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.save": "Enregistrer",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"authoring.loading": "Chargement...",
|
||||
"authoring.alert.error.permission": "Vous n'êtes pas autorisé à afficher cette page. Si vous croyez que vous devriez avoir accès à cette page, veuillez contacter l'équipe administrative du cours pour obtenir la permission.",
|
||||
"authoring.alert.save.error.connection": "Nous avons rencontré une erreur technique lors de l'application des modifications. Cela peut être un problème temporaire, veuillez donc réessayer dans quelques minutes. Si le problème persiste, accédez à {support_link} pour obtenir de l'aide.",
|
||||
"course-authoring.page.title": "Création de cours | {siteName}",
|
||||
"authoring.alert.support.text": "Page de support",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.cancel": "Annuler",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.save": "Sauvegarder",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"authoring.loading": "Loading...",
|
||||
"authoring.alert.error.permission": "You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.",
|
||||
"authoring.alert.save.error.connection": "We encountered a technical error when applying changes. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {supportLink} for help.",
|
||||
"course-authoring.page.title": "Course Authoring | {siteName}",
|
||||
"authoring.alert.support.text": "Support Page",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.cancel": "Cancel",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.save": "Save",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"authoring.loading": "Loading...",
|
||||
"authoring.alert.error.permission": "You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.",
|
||||
"authoring.alert.save.error.connection": "We encountered a technical error when applying changes. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {supportLink} for help.",
|
||||
"course-authoring.page.title": "Course Authoring | {siteName}",
|
||||
"authoring.alert.support.text": "Support Page",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.cancel": "Cancel",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.save": "Save",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"authoring.loading": "Loading...",
|
||||
"authoring.alert.error.permission": "You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.",
|
||||
"authoring.alert.save.error.connection": "We encountered a technical error when applying changes. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {supportLink} for help.",
|
||||
"course-authoring.page.title": "Course Authoring | {siteName}",
|
||||
"authoring.alert.support.text": "Support Page",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.cancel": "Cancel",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.save": "Save",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"authoring.loading": "Loading...",
|
||||
"authoring.alert.error.permission": "You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.",
|
||||
"authoring.alert.save.error.connection": "We encountered a technical error when applying changes. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {supportLink} for help.",
|
||||
"course-authoring.page.title": "Course Authoring | {siteName}",
|
||||
"authoring.alert.support.text": "Support Page",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.cancel": "Cancel",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.save": "Save",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"authoring.loading": "Loading...",
|
||||
"authoring.alert.error.permission": "You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.",
|
||||
"authoring.alert.save.error.connection": "We encountered a technical error when applying changes. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {supportLink} for help.",
|
||||
"course-authoring.page.title": "Course Authoring | {siteName}",
|
||||
"authoring.alert.support.text": "Support Page",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.cancel": "Cancel",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.save": "Save",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"authoring.loading": "Loading...",
|
||||
"authoring.alert.error.permission": "You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.",
|
||||
"authoring.alert.save.error.connection": "We encountered a technical error when applying changes. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {supportLink} for help.",
|
||||
"course-authoring.page.title": "Course Authoring | {siteName}",
|
||||
"authoring.alert.support.text": "Support Page",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.cancel": "Cancel",
|
||||
"course-authoring.pages-resources.app-settings-modal.button.save": "Save",
|
||||
|
||||
@@ -53,6 +53,7 @@ initialize({
|
||||
CALCULATOR_HELP_URL: process.env.CALCULATOR_HELP_URL || null,
|
||||
ENABLE_PROGRESS_GRAPH_SETTINGS: process.env.ENABLE_PROGRESS_GRAPH_SETTINGS || 'false',
|
||||
ENABLE_TEAM_TYPE_SETTING: process.env.ENABLE_TEAM_TYPE_SETTING === 'true',
|
||||
BBB_LEARN_MORE_URL: process.env.BBB_LEARN_MORE_URL || '',
|
||||
}, 'CourseAuthoringConfig');
|
||||
},
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getLoadingStatus } from './data/selectors';
|
||||
import PagesAndResourcesProvider from './PagesAndResourcesProvider';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
|
||||
function PagesAndResources({ courseId, intl }) {
|
||||
const PagesAndResources = ({ courseId, intl }) => {
|
||||
const { path, url } = useRouteMatch();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
@@ -34,6 +34,7 @@ function PagesAndResources({ courseId, intl }) {
|
||||
// Each page here is driven by a course app
|
||||
const pages = useModels('courseApps', courseAppIds);
|
||||
if (loadingStatus === RequestStatus.IN_PROGRESS) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@ function PagesAndResources({ courseId, intl }) {
|
||||
</main>
|
||||
</PagesAndResourcesProvider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
PagesAndResources.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const PagesAndResourcesContext = React.createContext({});
|
||||
|
||||
export default function PagesAndResourcesProvider({ courseId, children }) {
|
||||
const PagesAndResourcesProvider = ({ courseId, children }) => {
|
||||
const contextValue = useMemo(() => ({
|
||||
courseId,
|
||||
path: `/course/${courseId}/pages-and-resources`,
|
||||
}), []);
|
||||
return (
|
||||
<PagesAndResourcesContext.Provider
|
||||
value={{
|
||||
courseId,
|
||||
path: `/course/${courseId}/pages-and-resources`,
|
||||
}}
|
||||
value={contextValue}
|
||||
>
|
||||
{children}
|
||||
</PagesAndResourcesContext.Provider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
PagesAndResourcesProvider.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default PagesAndResourcesProvider;
|
||||
|
||||
@@ -33,21 +33,19 @@ import AppConfigFormDivider from '../discussions/app-config-form/apps/shared/App
|
||||
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
|
||||
import messages from './messages';
|
||||
|
||||
function AppSettingsForm({
|
||||
const AppSettingsForm = ({
|
||||
formikProps, children, showForm,
|
||||
}) {
|
||||
return children && (
|
||||
<TransitionReplace>
|
||||
{showForm ? (
|
||||
<React.Fragment key="app-enabled">
|
||||
{children(formikProps)}
|
||||
</React.Fragment>
|
||||
}) => children && (
|
||||
<TransitionReplace>
|
||||
{showForm ? (
|
||||
<React.Fragment key="app-enabled">
|
||||
{children(formikProps)}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment key="app-disabled" />
|
||||
)}
|
||||
</TransitionReplace>
|
||||
</TransitionReplace>
|
||||
);
|
||||
}
|
||||
|
||||
AppSettingsForm.propTypes = {
|
||||
// Ignore the warning here since we're just passing along the props as-is and the child component should validate
|
||||
@@ -61,38 +59,36 @@ AppSettingsForm.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
function AppSettingsModalBase({
|
||||
const AppSettingsModalBase = ({
|
||||
intl, title, onClose, variant, isMobile, children, footer,
|
||||
}) {
|
||||
return (
|
||||
<ModalDialog
|
||||
title={title}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
variant={variant}
|
||||
hasCloseButton={isMobile}
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title data-testid="modal-title">
|
||||
{title}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
{children}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer className="p-4">
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{intl.formatMessage(messages.cancel)}
|
||||
</ModalDialog.CloseButton>
|
||||
{footer}
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</ModalDialog>
|
||||
}) => (
|
||||
<ModalDialog
|
||||
title={title}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
variant={variant}
|
||||
hasCloseButton={isMobile}
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title data-testid="modal-title">
|
||||
{title}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
{children}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer className="p-4">
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{intl.formatMessage(messages.cancel)}
|
||||
</ModalDialog.CloseButton>
|
||||
{footer}
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</ModalDialog>
|
||||
);
|
||||
}
|
||||
|
||||
AppSettingsModalBase.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
@@ -108,7 +104,7 @@ AppSettingsModalBase.defaultProps = {
|
||||
footer: null,
|
||||
};
|
||||
|
||||
function AppSettingsModal({
|
||||
const AppSettingsModal = ({
|
||||
intl,
|
||||
appId,
|
||||
title,
|
||||
@@ -122,7 +118,7 @@ function AppSettingsModal({
|
||||
enableAppHelp,
|
||||
learnMoreText,
|
||||
enableReinitialize,
|
||||
}) {
|
||||
}) => {
|
||||
const { courseId } = useContext(PagesAndResourcesContext);
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
const updateSettingsRequestStatus = useSelector(getSavingStatus);
|
||||
@@ -271,7 +267,7 @@ function AppSettingsModal({
|
||||
{loadingStatus === RequestStatus.DENIED && <PermissionDeniedAlert />}
|
||||
</AppSettingsModalBase>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
AppSettingsModal.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
@@ -279,8 +275,8 @@ AppSettingsModal.propTypes = {
|
||||
appId: PropTypes.string.isRequired,
|
||||
children: PropTypes.func,
|
||||
onSettingsSave: PropTypes.func,
|
||||
initialValues: PropTypes.objectOf(PropTypes.any),
|
||||
validationSchema: PropTypes.objectOf(PropTypes.any),
|
||||
initialValues: PropTypes.shape({}),
|
||||
validationSchema: PropTypes.shape({}),
|
||||
onClose: PropTypes.func.isRequired,
|
||||
enableAppLabel: PropTypes.string.isRequired,
|
||||
enableAppHelp: PropTypes.string.isRequired,
|
||||
|
||||
@@ -6,18 +6,16 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
function CalculatorSettings({ intl, onClose }) {
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId="calculator"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableCalculatorHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableCalculatorLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableCalculatorLink)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
const CalculatorSettings = ({ intl, onClose }) => (
|
||||
<AppSettingsModal
|
||||
appId="calculator"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableCalculatorHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableCalculatorLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableCalculatorLink)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
CalculatorSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const DiscussionsContext = React.createContext({});
|
||||
|
||||
export default function DiscussionsProvider({ children, path }) {
|
||||
const DiscussionsProvider = ({ children, path }) => {
|
||||
const contextValue = useMemo(() => ({ path }), []);
|
||||
return (
|
||||
<DiscussionsContext.Provider
|
||||
value={{
|
||||
path,
|
||||
}}
|
||||
value={contextValue}
|
||||
>
|
||||
{children}
|
||||
</DiscussionsContext.Provider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
DiscussionsProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default DiscussionsProvider;
|
||||
|
||||
@@ -28,7 +28,7 @@ import Loading from '../../generic/Loading';
|
||||
const SELECTION_STEP = 'selection';
|
||||
const SETTINGS_STEP = 'settings';
|
||||
|
||||
function DiscussionsSettings({ courseId, intl }) {
|
||||
const DiscussionsSettings = ({ courseId, intl }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
|
||||
const { status, hasValidationError } = useSelector(state => state.discussions);
|
||||
@@ -145,7 +145,7 @@ function DiscussionsSettings({ courseId, intl }) {
|
||||
</AppConfigForm.Provider>
|
||||
</DiscussionsProvider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
DiscussionsSettings.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
|
||||
@@ -28,9 +28,9 @@ import AppConfigFormProvider, { AppConfigFormContext } from './AppConfigFormProv
|
||||
import AppConfigFormSaveButton from './AppConfigFormSaveButton';
|
||||
import messages from './messages';
|
||||
|
||||
function AppConfigForm({
|
||||
const AppConfigForm = ({
|
||||
courseId, intl,
|
||||
}) {
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { formRef } = useContext(AppConfigFormContext);
|
||||
@@ -141,7 +141,7 @@ function AppConfigForm({
|
||||
</ModalDialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
AppConfigForm.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const AppConfigFormContext = React.createContext({});
|
||||
|
||||
export default function AppConfigFormProvider({ children }) {
|
||||
const AppConfigFormProvider = ({ children }) => {
|
||||
const formRef = React.createRef();
|
||||
const contextValue = useMemo(() => ({ formRef }), []);
|
||||
|
||||
return (
|
||||
<AppConfigFormContext.Provider
|
||||
value={{
|
||||
formRef,
|
||||
}}
|
||||
value={contextValue}
|
||||
>
|
||||
{children}
|
||||
</AppConfigFormContext.Provider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
AppConfigFormProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default AppConfigFormProvider;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SAVING } from '../data/slice';
|
||||
import { AppConfigFormContext } from './AppConfigFormProvider';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
function AppConfigFormSaveButton({ intl, labelText }) {
|
||||
const AppConfigFormSaveButton = ({ intl, labelText }) => {
|
||||
const saveStatus = useSelector(state => state.discussions.saveStatus);
|
||||
const { selectedAppId } = useSelector((state) => state.discussions);
|
||||
|
||||
@@ -45,7 +45,7 @@ function AppConfigFormSaveButton({ intl, labelText }) {
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
AppConfigFormSaveButton.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useModel } from '../../../../../generic/model-store';
|
||||
|
||||
ensureConfig(['SITE_NAME', 'SUPPORT_EMAIL'], 'LTI Config Form');
|
||||
|
||||
function LtiConfigForm({ onSubmit, intl, formRef }) {
|
||||
const LtiConfigForm = ({ onSubmit, intl, formRef }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { selectedAppId, piiConfig } = useSelector((state) => state.discussions);
|
||||
@@ -177,7 +177,7 @@ function LtiConfigForm({ onSubmit, intl, formRef }) {
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
LtiConfigForm.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -21,9 +21,9 @@ import OpenedXConfigFormProvider from './OpenedXConfigFormProvider';
|
||||
|
||||
setupYupExtensions();
|
||||
|
||||
function OpenedXConfigForm({
|
||||
const OpenedXConfigForm = ({
|
||||
onSubmit, formRef, intl, legacy,
|
||||
}) {
|
||||
}) => {
|
||||
const {
|
||||
selectedAppId, enableGradedUnits, discussionTopicIds, divideDiscussionIds,
|
||||
} = useSelector(state => state.discussions);
|
||||
@@ -37,7 +37,7 @@ function OpenedXConfigForm({
|
||||
unitLevelVisibility: true,
|
||||
allowAnonymousPostsPeers: appConfigObj?.allowAnonymousPostsPeers || false,
|
||||
reportedContentEmailNotifications: appConfigObj?.reportedContentEmailNotifications || false,
|
||||
enableReportedContentEmailNotifications: appConfigObj?.enableReportedContentEmailNotifications || false,
|
||||
enableReportedContentEmailNotifications: Boolean(appConfigObj?.enableReportedContentEmailNotifications) || false,
|
||||
blackoutDates: appConfigObj?.blackoutDates || [],
|
||||
discussionTopics: discussionTopicsModel || [],
|
||||
divideByCohorts: appConfigObj?.divideByCohorts || false,
|
||||
@@ -52,6 +52,7 @@ function OpenedXConfigForm({
|
||||
groupAtSubsection: Yup.bool().default(false),
|
||||
};
|
||||
const validationSchema = Yup.object().shape({
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
blackoutDates: Yup.array(
|
||||
Yup.object().shape({
|
||||
startDate: Yup.string()
|
||||
@@ -76,6 +77,7 @@ function OpenedXConfigForm({
|
||||
}),
|
||||
}),
|
||||
),
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
discussionTopics: Yup.array(
|
||||
Yup.object({
|
||||
name: Yup.string().required(intl.formatMessage(messages.discussionTopicRequired)),
|
||||
@@ -145,7 +147,7 @@ function OpenedXConfigForm({
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
OpenedXConfigForm.propTypes = {
|
||||
legacy: PropTypes.bool.isRequired,
|
||||
|
||||
@@ -50,6 +50,7 @@ const defaultAppConfig = (divideDiscussionIds = []) => ({
|
||||
enableReportedContentEmailNotifications: false,
|
||||
allowDivisionByUnit: false,
|
||||
blackoutDates: [],
|
||||
cohortsEnabled: false,
|
||||
});
|
||||
describe('OpenedXConfigForm', () => {
|
||||
let axiosMock;
|
||||
@@ -141,14 +142,17 @@ describe('OpenedXConfigForm', () => {
|
||||
...legacyApiResponse.plugin_configuration,
|
||||
reported_content_email_notifications_flag: true,
|
||||
divided_course_wide_discussions: [],
|
||||
available_division_schemes: [],
|
||||
},
|
||||
});
|
||||
createComponent();
|
||||
const { divideDiscussionIds } = defaultAppConfig(['13f106c6-6735-4e84-b097-0456cff55960', 'course']);
|
||||
|
||||
// DivisionByGroupFields
|
||||
|
||||
expect(container.querySelector('#alert')).toBeInTheDocument();
|
||||
expect(container.querySelector('#divideByCohorts')).toBeInTheDocument();
|
||||
expect(container.querySelector('#divideByCohorts')).not.toBeChecked();
|
||||
expect(container.querySelector('#divideByCohorts')).toBeDisabled();
|
||||
expect(container.querySelector('#divideCourseTopicsByCohorts')).not.toBeInTheDocument();
|
||||
|
||||
divideDiscussionIds.forEach(id => expect(
|
||||
@@ -179,6 +183,7 @@ describe('OpenedXConfigForm', () => {
|
||||
reported_content_email_notifications_flag: true,
|
||||
always_divide_inline_discussions: true,
|
||||
divided_course_wide_discussions: [],
|
||||
available_division_schemes: ['cohorts'],
|
||||
},
|
||||
});
|
||||
createComponent();
|
||||
@@ -186,14 +191,10 @@ describe('OpenedXConfigForm', () => {
|
||||
|
||||
// DivisionByGroupFields
|
||||
expect(container.querySelector('#divideByCohorts')).toBeInTheDocument();
|
||||
expect(container.querySelector('#divideByCohorts')).toBeChecked();
|
||||
expect(container.querySelector('#divideByCohorts')).not.toBeChecked();
|
||||
expect(
|
||||
container.querySelector('#divideCourseTopicsByCohorts'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector('#divideCourseTopicsByCohorts'),
|
||||
).not.toBeChecked();
|
||||
|
||||
).not.toBeInTheDocument();
|
||||
divideDiscussionIds.forEach(id => expect(
|
||||
container.querySelector(`#checkbox-${id}`),
|
||||
).not.toBeInTheDocument());
|
||||
@@ -211,7 +212,8 @@ describe('OpenedXConfigForm', () => {
|
||||
expect(container.querySelector('#reportedContentEmailNotifications')).toBeChecked();
|
||||
});
|
||||
|
||||
test('folded discussion topics are in the DOM when divideByCohorts and divideCourseWideTopics are enabled',
|
||||
test(
|
||||
'folded discussion topics are in the DOM when divideByCohorts and divideCourseWideTopics are enabled',
|
||||
async () => {
|
||||
await mockStore({
|
||||
...legacyApiResponse,
|
||||
@@ -229,15 +231,13 @@ describe('OpenedXConfigForm', () => {
|
||||
|
||||
// DivisionByGroupFields
|
||||
expect(container.querySelector('#divideByCohorts')).toBeInTheDocument();
|
||||
expect(container.querySelector('#divideByCohorts')).toBeChecked();
|
||||
expect(container.querySelector('#divideCourseTopicsByCohorts')).toBeInTheDocument();
|
||||
expect(container.querySelector('#divideCourseTopicsByCohorts')).toBeChecked();
|
||||
|
||||
expect(container.querySelector('#divideByCohorts')).not.toBeChecked();
|
||||
expect(container.querySelector('#divideCourseTopicsByCohorts')).not.toBeInTheDocument();
|
||||
divideDiscussionIds.forEach(id => {
|
||||
expect(container.querySelector(`#checkbox-${id}`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`#checkbox-${id}`)).toBeChecked();
|
||||
expect(container.querySelector(`#checkbox-${id}`)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const updateTopicName = async (topicId, topicName) => {
|
||||
const topicCard = queryByTestId(container, topicId);
|
||||
@@ -265,7 +265,8 @@ describe('OpenedXConfigForm', () => {
|
||||
expect(store.getState().discussions.hasValidationError).toBe(expectExists);
|
||||
};
|
||||
|
||||
test('show required error on field when leaving empty topic name',
|
||||
test(
|
||||
'show required error on field when leaving empty topic name',
|
||||
async () => {
|
||||
await mockStore(legacyApiResponse);
|
||||
createComponent();
|
||||
@@ -274,7 +275,8 @@ describe('OpenedXConfigForm', () => {
|
||||
await waitForElementToBeRemoved(queryByText(topicCard, messages.addTopicHelpText.defaultMessage));
|
||||
assertTopicNameRequiredValidation(topicCard);
|
||||
assertHasErrorValidation();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('check field is not collapsible in case of error', async () => {
|
||||
await mockStore(legacyApiResponse);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { updateValidationStatus } from '../../../data/slice';
|
||||
|
||||
export const OpenedXConfigFormContext = createContext({});
|
||||
|
||||
export default function OpenedXConfigFormProvider({ children, value }) {
|
||||
const OpenedXConfigFormProvider = ({ children, value }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -17,7 +17,7 @@ export default function OpenedXConfigFormProvider({ children, value }) {
|
||||
{children}
|
||||
</OpenedXConfigFormContext.Provider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
OpenedXConfigFormProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
@@ -33,3 +33,5 @@ OpenedXConfigFormProvider.propTypes = {
|
||||
isFormInvalid: PropTypes.bool,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default OpenedXConfigFormProvider;
|
||||
|
||||
@@ -5,27 +5,25 @@ import FormSwitchGroup from '../../../../../generic/FormSwitchGroup';
|
||||
import messages from '../../messages';
|
||||
import AppConfigFormDivider from './AppConfigFormDivider';
|
||||
|
||||
function AnonymousPostingFields({
|
||||
const AnonymousPostingFields = ({
|
||||
onBlur,
|
||||
onChange,
|
||||
intl,
|
||||
values,
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<h5 className="mt-4 text-gray-500">{intl.formatMessage(messages.anonymousPosting)}</h5>
|
||||
<AppConfigFormDivider />
|
||||
<FormSwitchGroup
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
id="allowAnonymousPostsPeers"
|
||||
checked={values.allowAnonymousPostsPeers}
|
||||
label={intl.formatMessage(messages.allowAnonymousPostsPeersLabel)}
|
||||
helpText={intl.formatMessage(messages.allowAnonymousPostsPeersHelp)}
|
||||
/>
|
||||
</>
|
||||
}) => (
|
||||
<>
|
||||
<h5 className="mt-4 text-gray-500">{intl.formatMessage(messages.anonymousPosting)}</h5>
|
||||
<AppConfigFormDivider />
|
||||
<FormSwitchGroup
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
id="allowAnonymousPostsPeers"
|
||||
checked={values.allowAnonymousPostsPeers}
|
||||
label={intl.formatMessage(messages.allowAnonymousPostsPeersLabel)}
|
||||
helpText={intl.formatMessage(messages.allowAnonymousPostsPeersHelp)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
AnonymousPostingFields.propTypes = {
|
||||
onBlur: PropTypes.func.isRequired,
|
||||
|
||||
@@ -2,22 +2,20 @@ import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
export default function AppConfigFormDivider({ thick, marginAdj }) {
|
||||
return (
|
||||
<hr
|
||||
className={classNames(
|
||||
const AppConfigFormDivider = ({ thick, marginAdj }) => (
|
||||
<hr
|
||||
className={classNames(
|
||||
'my-2 mx-n4 border-light-300',
|
||||
{
|
||||
[`mx-sm-n${marginAdj.sm}`]: marginAdj.sm !== null,
|
||||
[`mx-n${marginAdj.default}`]: marginAdj.default !== null,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
style={{
|
||||
borderTopWidth: thick ? '3px' : '1px',
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
AppConfigFormDivider.propTypes = {
|
||||
thick: PropTypes.bool,
|
||||
@@ -34,3 +32,5 @@ AppConfigFormDivider.defaultProps = {
|
||||
sm: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export default AppConfigFormDivider;
|
||||
|
||||
@@ -9,13 +9,13 @@ import AppConfigFormDivider from './AppConfigFormDivider';
|
||||
|
||||
import messages from '../lti/messages';
|
||||
|
||||
function AppExternalLinks({
|
||||
const AppExternalLinks = ({
|
||||
externalLinks,
|
||||
intl,
|
||||
providerName,
|
||||
showLaunchIcon,
|
||||
customClasses,
|
||||
}) {
|
||||
}) => {
|
||||
const { contactEmail, ...links } = externalLinks;
|
||||
const linkTypes = Object.keys(links).filter(key => links[key]);
|
||||
return (
|
||||
@@ -60,7 +60,7 @@ function AppExternalLinks({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
AppExternalLinks.propTypes = {
|
||||
externalLinks: PropTypes.shape({
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import React, { useEffect, useContext } from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Form, TransitionReplace } from '@edx/paragon';
|
||||
import {
|
||||
Form, TransitionReplace, Hyperlink, Alert,
|
||||
} from '@edx/paragon';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { FieldArray, useFormikContext } from 'formik';
|
||||
import _ from 'lodash';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import FormSwitchGroup from '../../../../../generic/FormSwitchGroup';
|
||||
import messages from '../../messages';
|
||||
import AppConfigFormDivider from './AppConfigFormDivider';
|
||||
@@ -21,8 +25,13 @@ const DivisionByGroupFields = ({ intl }) => {
|
||||
discussionTopics,
|
||||
divideByCohorts,
|
||||
divideCourseTopicsByCohorts,
|
||||
cohortsEnabled,
|
||||
} = appConfig;
|
||||
|
||||
const { courseId } = useParams();
|
||||
const { config } = useContext(AppContext);
|
||||
const learningCourseURL = `${config.LMS_BASE_URL}/courses/${courseId}/instructor`;
|
||||
|
||||
useEffect(() => {
|
||||
if (divideByCohorts) {
|
||||
if (!divideCourseTopicsByCohorts && _.size(discussionTopics) !== _.size(divideDiscussionIds)) {
|
||||
@@ -56,20 +65,30 @@ const DivisionByGroupFields = ({ intl }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<h5 className="text-gray-500 mb-2 mt-4">
|
||||
<h5 className="text-gray-500 mb-4 mt-4">
|
||||
{intl.formatMessage(messages.divisionByGroup)}
|
||||
</h5>
|
||||
{!cohortsEnabled
|
||||
&& (
|
||||
<Alert className="bg-light-200 font-weight-normal h5" id="alert">
|
||||
{intl.formatMessage(messages.cohortsEnabled)}
|
||||
<Hyperlink destination={learningCourseURL} target="_blank">
|
||||
{intl.formatMessage(messages.instructorDashboard)}
|
||||
</Hyperlink>
|
||||
</Alert>
|
||||
)}
|
||||
<FormSwitchGroup
|
||||
onChange={handleChange}
|
||||
className="mt-2"
|
||||
onBlur={handleBlur}
|
||||
id="divideByCohorts"
|
||||
checked={divideByCohorts}
|
||||
checked={cohortsEnabled === false ? cohortsEnabled : divideByCohorts}
|
||||
label={intl.formatMessage(messages.divideByCohortsLabel)}
|
||||
helpText={intl.formatMessage(messages.divideByCohortsHelp)}
|
||||
disabled={!cohortsEnabled}
|
||||
/>
|
||||
<TransitionReplace>
|
||||
{divideByCohorts ? (
|
||||
{(divideByCohorts && cohortsEnabled) ? (
|
||||
<React.Fragment key="open">
|
||||
<AppConfigFormDivider />
|
||||
<FormSwitchGroup
|
||||
|
||||
@@ -1,27 +1,57 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useFormikContext } from 'formik';
|
||||
import FormSwitchGroup from '../../../../../generic/FormSwitchGroup';
|
||||
import messages from '../../messages';
|
||||
import AppConfigFormDivider from './AppConfigFormDivider';
|
||||
import ConfirmationPopup from '../../../../../generic/ConfirmationPopup';
|
||||
|
||||
function InContextDiscussionFields({
|
||||
const InContextDiscussionFields = ({
|
||||
onBlur,
|
||||
onChange,
|
||||
intl,
|
||||
values,
|
||||
}) {
|
||||
}) => {
|
||||
const {
|
||||
setFieldValue,
|
||||
} = useFormikContext();
|
||||
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
|
||||
const handleConfirmation = () => {
|
||||
setFieldValue('enableGradedUnits', !values.enableGradedUnits);
|
||||
setShowPopup(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h5 className="text-gray-500 mt-4">{intl.formatMessage(messages.visibilityInContext)}</h5>
|
||||
<FormSwitchGroup
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
id="enableGradedUnits"
|
||||
checked={values.enableGradedUnits}
|
||||
label={intl.formatMessage(messages.gradedUnitPagesLabel)}
|
||||
helpText={intl.formatMessage(messages.gradedUnitPagesHelp)}
|
||||
/>
|
||||
{showPopup
|
||||
? (
|
||||
<ConfirmationPopup
|
||||
label={values.enableGradedUnits
|
||||
? intl.formatMessage(messages.cancelEnableDiscussionsLabel)
|
||||
: intl.formatMessage(messages.confirmEnableDiscussionsLabel)}
|
||||
bodyText={values.enableGradedUnits
|
||||
? intl.formatMessage(messages.cancelEnableDiscussions)
|
||||
: intl.formatMessage(messages.confirmEnableDiscussions)}
|
||||
onConfirm={handleConfirmation}
|
||||
confirmLabel={intl.formatMessage(messages.confirm)}
|
||||
onCancel={() => setShowPopup(false)}
|
||||
cancelLabel={intl.formatMessage(messages.cancelButton)}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<FormSwitchGroup
|
||||
onChange={() => setShowPopup(true)}
|
||||
onBlur={onBlur}
|
||||
id="enableGradedUnits"
|
||||
checked={values.enableGradedUnits}
|
||||
label={intl.formatMessage(messages.gradedUnitPagesLabel)}
|
||||
helpText={intl.formatMessage(messages.gradedUnitPagesHelp)}
|
||||
/>
|
||||
)}
|
||||
<AppConfigFormDivider />
|
||||
<FormSwitchGroup
|
||||
onChange={onChange}
|
||||
@@ -33,7 +63,7 @@ function InContextDiscussionFields({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
InContextDiscussionFields.propTypes = {
|
||||
onBlur: PropTypes.func.isRequired,
|
||||
|
||||
@@ -5,7 +5,7 @@ import FormSwitchGroup from '../../../../../generic/FormSwitchGroup';
|
||||
import AppConfigFormDivider from './AppConfigFormDivider';
|
||||
import messages from '../../messages';
|
||||
|
||||
function ReportedContentEmailNotifications({ intl }) {
|
||||
const ReportedContentEmailNotifications = ({ intl }) => {
|
||||
const {
|
||||
handleChange,
|
||||
handleBlur,
|
||||
@@ -13,6 +13,7 @@ function ReportedContentEmailNotifications({ intl }) {
|
||||
} = useFormikContext();
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
<>
|
||||
{values.enableReportedContentEmailNotifications && (
|
||||
<div>
|
||||
@@ -31,7 +32,7 @@ function ReportedContentEmailNotifications({ intl }) {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ReportedContentEmailNotifications.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
badgeVariant,
|
||||
} from '../../../../data/constants';
|
||||
import CollapsableEditor from '../../../../../../generic/CollapsableEditor';
|
||||
import DeletePopup from '../../../../../../generic/DeletePopup';
|
||||
import ConfirmationPopup from '../../../../../../generic/ConfirmationPopup';
|
||||
import CollapseCardHeading from './CollapseCardHeading';
|
||||
|
||||
const BlackoutDatesItem = ({
|
||||
@@ -51,13 +51,13 @@ const BlackoutDatesItem = ({
|
||||
|
||||
if (showDeletePopup) {
|
||||
return (
|
||||
<DeletePopup
|
||||
<ConfirmationPopup
|
||||
label={blackoutDate.status === constants.ACTIVE
|
||||
? intl.formatMessage(messages.activeBlackoutDatesDeletionLabel)
|
||||
: intl.formatMessage(messages.blackoutDatesDeletionLabel)}
|
||||
bodyText={intl.formatMessage(deleteHelperText[blackoutDate.status])}
|
||||
onDelete={onDelete}
|
||||
deleteLabel={intl.formatMessage(messages.deleteButton)}
|
||||
onConfirm={onDelete}
|
||||
confirmLabel={intl.formatMessage(messages.deleteButton)}
|
||||
onCancel={() => setShowDeletePopup(false)}
|
||||
cancelLabel={intl.formatMessage(messages.cancelButton)}
|
||||
/>
|
||||
|
||||
@@ -28,11 +28,36 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Button allowing the user to return to discussion provider configurations.',
|
||||
},
|
||||
confirm: {
|
||||
id: 'authoring.discussions.confirm',
|
||||
defaultMessage: 'Confirm',
|
||||
description: 'Button allowing the user to confirm Confirmation.',
|
||||
},
|
||||
confirmConfigurationChange: {
|
||||
id: 'authoring.discussions.confirmConfigurationChange',
|
||||
defaultMessage: 'Are you sure you want to change the discussion settings?',
|
||||
description: 'Asks the user whether he/she really wants to change settings.',
|
||||
},
|
||||
confirmEnableDiscussionsLabel: {
|
||||
id: 'authoring.discussions.confirmEnableDiscussionsLabel',
|
||||
defaultMessage: 'Enable discussions on units in graded subsections?',
|
||||
description: 'Asks the user whether he/she really wants to enable discussions on units in graded subsections.',
|
||||
},
|
||||
cancelEnableDiscussionsLabel: {
|
||||
id: 'authoring.discussions.cancelEnableDiscussionsLabel',
|
||||
defaultMessage: 'Disable discussions on units in graded subsections?',
|
||||
description: 'Asks the user whether he/she really wants to disable discussions on units in graded subsections.',
|
||||
},
|
||||
confirmEnableDiscussions: {
|
||||
id: 'authoring.discussions.confirmEnableDiscussions',
|
||||
defaultMessage: 'Enabling this toggle will automatically enable discussion on all units in graded subsections, that are not timed exams.',
|
||||
description: 'Asks the user whether he/she really wants to enable discussions on units in graded subsections.',
|
||||
},
|
||||
cancelEnableDiscussions: {
|
||||
id: 'authoring.discussions.cancelEnableDiscussions',
|
||||
defaultMessage: 'Disabling this toggle will automatically disable discussion on all units in graded subsections. Discussion topics containing at least 1 thread will be listed and accessible under “Archived” in Topics tab on the Discussions page.',
|
||||
description: 'Asks the user whether he/she really wants to disable discussions on units in graded subsections.',
|
||||
},
|
||||
backButton: {
|
||||
id: 'authoring.discussions.backButton',
|
||||
defaultMessage: 'Back',
|
||||
@@ -124,7 +149,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Questions for the TAs',
|
||||
description: 'Label for a checkbox allowing a user to divide the Questions for the TAs (TA stands for "teaching assistant") course wide topic by cohorts.',
|
||||
},
|
||||
|
||||
cohortsEnabled: {
|
||||
id: 'authoring.discussions.builtIn.cohortsEnabled.label',
|
||||
defaultMessage: 'To adjust these settings, enable cohorts on the ',
|
||||
description: 'Label text informing the user to enable cohort',
|
||||
},
|
||||
instructorDashboard: {
|
||||
id: 'authoring.discussions.builtIn.instructorDashboard.label',
|
||||
defaultMessage: 'instructor dashboard',
|
||||
description: 'Label text for instructor dashboard',
|
||||
},
|
||||
// In-context discussion fields
|
||||
visibilityInContext: {
|
||||
id: 'authoring.discussions.builtIn.visibilityInContext',
|
||||
|
||||
@@ -38,9 +38,7 @@ export const hasValidTimeFormat = (time) => time && moment(time, validTimeFormat
|
||||
export const startOfDayTime = (time) => time || moment().startOf('day').format('HH:mm');
|
||||
export const endOfDayTime = (time) => time || moment().endOf('day').format('HH:mm');
|
||||
export const normalizeTime = (time) => time && moment(time, validTimeFormats, true).format('HH:mm');
|
||||
export const normalizeDate = (date) => moment(
|
||||
date, ['MM/DD/YYYY', 'YYYY-MM-DDTHH:mm', 'YYYY-MM-DD'], true,
|
||||
).format('YYYY-MM-DD');
|
||||
export const normalizeDate = (date) => moment(date, ['MM/DD/YYYY', 'YYYY-MM-DDTHH:mm', 'YYYY-MM-DD'], true).format('YYYY-MM-DD');
|
||||
|
||||
export const decodeDateTime = (date, time) => {
|
||||
const nDate = normalizeDate(date);
|
||||
@@ -50,8 +48,11 @@ export const decodeDateTime = (date, time) => {
|
||||
};
|
||||
|
||||
export const sortBlackoutDatesByStatus = (data, status, order) => (
|
||||
_.orderBy(data.filter(date => date.status === status),
|
||||
[(obj) => decodeDateTime(obj.startDate, startOfDayTime(obj.startTime))], [order])
|
||||
_.orderBy(
|
||||
data.filter(date => date.status === status),
|
||||
[(obj) => decodeDateTime(obj.startDate, startOfDayTime(obj.startTime))],
|
||||
[order],
|
||||
)
|
||||
);
|
||||
|
||||
export const formatBlackoutDates = ({
|
||||
|
||||
@@ -11,9 +11,9 @@ import messages from './messages';
|
||||
import appMessages from '../app-config-form/messages';
|
||||
import FeaturesList from './FeaturesList';
|
||||
|
||||
function AppCard({
|
||||
const AppCard = ({
|
||||
app, onClick, intl, selected, features,
|
||||
}) {
|
||||
}) => {
|
||||
const { canChangeProviders } = useSelector(state => state.courseDetail);
|
||||
const supportText = app.hasFullSupport
|
||||
? intl.formatMessage(messages.appFullSupport)
|
||||
@@ -62,7 +62,7 @@ function AppCard({
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
AppCard.propTypes = {
|
||||
app: PropTypes.shape({
|
||||
@@ -73,7 +73,7 @@ AppCard.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
selected: PropTypes.bool.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
features: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
features: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AppCard);
|
||||
|
||||
@@ -14,7 +14,7 @@ import FeaturesTable from './FeaturesTable';
|
||||
import AppListNextButton from './AppListNextButton';
|
||||
import Loading from '../../../generic/Loading';
|
||||
|
||||
function AppList({ intl }) {
|
||||
const AppList = ({ intl }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
@@ -90,7 +90,7 @@ function AppList({ intl }) {
|
||||
</Responsive>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
AppList.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react/jsx-no-constructed-context-values */
|
||||
import React from 'react';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DiscussionsContext } from '../DiscussionsProvider';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function AppListNextButton({ intl }) {
|
||||
const AppListNextButton = ({ intl }) => {
|
||||
const { selectedAppId } = useSelector(state => state.discussions);
|
||||
const { path: discussionsPath } = useContext(DiscussionsContext);
|
||||
|
||||
@@ -24,7 +24,7 @@ function AppListNextButton({ intl }) {
|
||||
{intl.formatMessage(messages.nextButton)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
AppListNextButton.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -41,9 +41,7 @@ describe('FeaturesList', () => {
|
||||
const button = getByRole(container, 'button');
|
||||
userEvent.click(button);
|
||||
app.featureIds.forEach((id) => {
|
||||
const featureNodes = queryAllByText(
|
||||
container, messages[`featureName-${id}`].defaultMessage,
|
||||
);
|
||||
const featureNodes = queryAllByText(container, messages[`featureName-${id}`].defaultMessage);
|
||||
expect(featureNodes.map(node => node.closest('div'))).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,37 +6,35 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import SupportedFeature from './SupportedFeature';
|
||||
import messages from './messages';
|
||||
|
||||
function FeaturesList({ app, intl }) {
|
||||
return (
|
||||
<Collapsible
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
title={(
|
||||
<>
|
||||
<Collapsible.Visible whenClosed>
|
||||
{intl.formatMessage(messages['supportedFeatureList-mobile-show'])}
|
||||
</Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen>
|
||||
{intl.formatMessage(messages['supportedFeatureList-mobile-hide'])}
|
||||
</Collapsible.Visible>
|
||||
</>
|
||||
const FeaturesList = ({ app, intl }) => (
|
||||
<Collapsible
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
title={(
|
||||
<>
|
||||
<Collapsible.Visible whenClosed>
|
||||
{intl.formatMessage(messages['supportedFeatureList-mobile-show'])}
|
||||
</Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen>
|
||||
{intl.formatMessage(messages['supportedFeatureList-mobile-hide'])}
|
||||
</Collapsible.Visible>
|
||||
</>
|
||||
)}
|
||||
styling="basic"
|
||||
>
|
||||
{app.featureIds.map((id) => (
|
||||
<div key={`collapsible-${app.id}&${id}`} className="d-flex mb-1">
|
||||
<SupportedFeature name={intl.formatMessage(messages[`featureName-${id}`])} />
|
||||
</div>
|
||||
styling="basic"
|
||||
>
|
||||
{app.featureIds.map((id) => (
|
||||
<div key={`collapsible-${app.id}&${id}`} className="d-flex mb-1">
|
||||
<SupportedFeature name={intl.formatMessage(messages[`featureName-${id}`])} />
|
||||
</div>
|
||||
))}
|
||||
</Collapsible>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export default injectIntl(FeaturesList);
|
||||
|
||||
FeaturesList.propTypes = {
|
||||
app: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
featureIds: PropTypes.array.isRequired,
|
||||
featureIds: PropTypes.shape([]).isRequired,
|
||||
}).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import appMessages from '../app-config-form/messages';
|
||||
import { FEATURE_TYPES } from '../data/constants';
|
||||
import './FeaturesTable.scss';
|
||||
|
||||
function FeaturesTable({ apps, features, intl }) {
|
||||
const FeaturesTable = ({ apps, features, intl }) => {
|
||||
const {
|
||||
basic, partial, full, common,
|
||||
} = _.groupBy(features, (feature) => feature.featureSupportType);
|
||||
@@ -87,12 +87,12 @@ function FeaturesTable({ apps, features, intl }) {
|
||||
<DataTable.Table />
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(FeaturesTable);
|
||||
|
||||
FeaturesTable.propTypes = {
|
||||
apps: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
features: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
apps: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
|
||||
features: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ const SupportedFeature = ({ name }) => (
|
||||
</span>
|
||||
{name}
|
||||
</>
|
||||
);
|
||||
);
|
||||
|
||||
SupportedFeature.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
|
||||
@@ -64,7 +64,7 @@ const messages = defineMessages({
|
||||
},
|
||||
'appDescription-openedx': {
|
||||
id: 'authoring.discussions.appList.appDescription-openedx',
|
||||
defaultMessage: 'Start conversations with other learners, ask questions, and interact with other learners in the course.',
|
||||
defaultMessage: 'Enable participation in discussion topics alongside course content.',
|
||||
description: 'A description of the new edX Discussions app.',
|
||||
},
|
||||
// Piazza
|
||||
|
||||
@@ -56,6 +56,7 @@ function normalizePluginConfig(data) {
|
||||
if (!data || Object.keys(data).length < 1) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const enableDivideByCohorts = data.always_divide_inline_discussions && data.division_scheme === 'cohort';
|
||||
const enableDivideCourseTopicsByCohorts = enableDivideByCohorts && data.divided_course_wide_discussions.length > 0;
|
||||
return {
|
||||
@@ -69,6 +70,8 @@ function normalizePluginConfig(data) {
|
||||
allowDivisionByUnit: false,
|
||||
divideByCohorts: enableDivideByCohorts,
|
||||
divideCourseTopicsByCohorts: enableDivideCourseTopicsByCohorts,
|
||||
cohortsEnabled: data.available_division_schemes?.includes('cohort') || false,
|
||||
groupAtSubsection: data.group_at_subsection,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -252,6 +252,7 @@ describe('Data layer integration tests', () => {
|
||||
alwaysDivideInlineDiscussions: false,
|
||||
allowDivisionByUnit: false,
|
||||
divideCourseTopicsByCohorts: false,
|
||||
cohortsEnabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -455,6 +456,7 @@ describe('Data layer integration tests', () => {
|
||||
allowDivisionsByUnit: true,
|
||||
alwaysDivideInlineDiscussions: true,
|
||||
divideCourseTopicsByCohorts: true,
|
||||
divisionScheme: DivisionSchemes.COHORT,
|
||||
divideDiscussionIds,
|
||||
discussionTopics: [
|
||||
{ name: 'Edx', id: '13f106c6-6735-4e84-b097-0456cff55960' },
|
||||
@@ -463,7 +465,6 @@ describe('Data layer integration tests', () => {
|
||||
},
|
||||
pagesAndResourcesPath,
|
||||
), store.dispatch);
|
||||
|
||||
expect(window.location.pathname).toEqual(pagesAndResourcesPath);
|
||||
expect(store.getState().discussions).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -490,6 +491,7 @@ describe('Data layer integration tests', () => {
|
||||
// happens, but NOT what we want to have happen!
|
||||
divideByCohorts: true,
|
||||
divisionScheme: DivisionSchemes.COHORT,
|
||||
cohortsEnabled: false,
|
||||
allowDivisionByUnit: false,
|
||||
divideCourseTopicsByCohorts: true,
|
||||
});
|
||||
|
||||
@@ -6,18 +6,16 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
function NotesSettings({ intl, onClose }) {
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId="edxnotes"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableNotesHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableNotesLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableNotesLink)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
const NotesSettings = ({ intl, onClose }) => (
|
||||
<AppSettingsModal
|
||||
appId="edxnotes"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableNotesHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableNotesLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableNotesLink)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
NotesSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Form, Hyperlink } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
@@ -8,18 +9,17 @@ import AppConfigFormDivider from '../discussions/app-config-form/apps/shared/App
|
||||
import LiveCommonFields from './LiveCommonFields';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
function BbbSettings({
|
||||
const BbbSettings = ({
|
||||
intl,
|
||||
values,
|
||||
setFieldValue,
|
||||
}) {
|
||||
}) => {
|
||||
const [bbbPlan, setBbbPlan] = useState(values.tierType);
|
||||
|
||||
useEffect(() => {
|
||||
setBbbPlan(values.tierType);
|
||||
}, [values.tierType]);
|
||||
|
||||
const appInfo = useModel('courseApps', 'live');
|
||||
const app = useModel('liveApps', 'big_blue_button');
|
||||
const isPiiDisabled = !values.piiSharingEnable;
|
||||
function getBbbPlanOptions() {
|
||||
@@ -71,7 +71,7 @@ function BbbSettings({
|
||||
</Form.Group>
|
||||
|
||||
<Hyperlink
|
||||
destination={appInfo.documentationLinks.learnMoreConfiguration}
|
||||
destination={getConfig().BBB_LEARN_MORE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
showLaunchIcon
|
||||
@@ -88,11 +88,19 @@ function BbbSettings({
|
||||
) : (
|
||||
<>
|
||||
{bbbPlan === bbbPlanTypes.commercial && <LiveCommonFields values={values} />}
|
||||
{bbbPlan === bbbPlanTypes.free
|
||||
&& (
|
||||
<p data-testid="free-plan-message">
|
||||
{bbbPlan === bbbPlanTypes.free && (
|
||||
<span data-testid="free-plan-message">
|
||||
{intl.formatMessage(messages.freePlanMessage)}
|
||||
</p>
|
||||
<Hyperlink
|
||||
destination="https://bigbluebutton.org/privacy-policy/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
showLaunchIcon
|
||||
className="text-gray-700 ml-1"
|
||||
>
|
||||
{intl.formatMessage(messages.privacyPolicy)}
|
||||
</Hyperlink>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -100,7 +108,7 @@ function BbbSettings({
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
BbbSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -107,7 +107,8 @@ describe('BBB Settings', () => {
|
||||
expect(getAllByRole(dropDown, 'option').length).toBe(noOfOptions);
|
||||
});
|
||||
|
||||
test('Connect to support and PII sharing message is visible and plans selection is disabled, When pii sharing is disabled, ',
|
||||
test(
|
||||
'Connect to support and PII sharing message is visible and plans selection is disabled, When pii sharing is disabled, ',
|
||||
async () => {
|
||||
await mockStore({ piiSharingAllowed: false });
|
||||
renderComponent();
|
||||
@@ -120,7 +121,8 @@ describe('BBB Settings', () => {
|
||||
);
|
||||
expect(helpRequestPiiText).toHaveTextContent(messages.piiSharingEnableHelpTextBbb.defaultMessage);
|
||||
expect(container.querySelector('select[name="tierType"]')).toBeDisabled();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('free plans message is visible when free plan is selected', async () => {
|
||||
await mockStore({ emailSharing: true, isFreeTier: true });
|
||||
|
||||
@@ -4,37 +4,35 @@ import PropTypes from 'prop-types';
|
||||
import messages from './messages';
|
||||
import FormikControl from '../../generic/FormikControl';
|
||||
|
||||
function LiveCommonFields({
|
||||
const LiveCommonFields = ({
|
||||
intl,
|
||||
values,
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
|
||||
<FormikControl
|
||||
name="consumerKey"
|
||||
value={values.consumerKey}
|
||||
floatingLabel={intl.formatMessage(messages.consumerKey)}
|
||||
className="pb-1"
|
||||
type="input"
|
||||
/>
|
||||
<FormikControl
|
||||
name="consumerSecret"
|
||||
value={values.consumerSecret}
|
||||
floatingLabel={intl.formatMessage(messages.consumerSecret)}
|
||||
className="pb-1"
|
||||
type="password"
|
||||
/>
|
||||
<FormikControl
|
||||
name="launchUrl"
|
||||
value={values.launchUrl}
|
||||
floatingLabel={intl.formatMessage(messages.launchUrl)}
|
||||
className="pb-1"
|
||||
type="input"
|
||||
/>
|
||||
</>
|
||||
}) => (
|
||||
<>
|
||||
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
|
||||
<FormikControl
|
||||
name="consumerKey"
|
||||
value={values.consumerKey}
|
||||
floatingLabel={intl.formatMessage(messages.consumerKey)}
|
||||
className="pb-1"
|
||||
type="input"
|
||||
/>
|
||||
<FormikControl
|
||||
name="consumerSecret"
|
||||
value={values.consumerSecret}
|
||||
floatingLabel={intl.formatMessage(messages.consumerSecret)}
|
||||
className="pb-1"
|
||||
type="password"
|
||||
/>
|
||||
<FormikControl
|
||||
name="launchUrl"
|
||||
value={values.launchUrl}
|
||||
floatingLabel={intl.formatMessage(messages.launchUrl)}
|
||||
className="pb-1"
|
||||
type="input"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
LiveCommonFields.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -16,10 +16,10 @@ import messages from './messages';
|
||||
import ZoomSettings from './ZoomSettings';
|
||||
import BBBSettings from './BBBSettings';
|
||||
|
||||
function LiveSettings({
|
||||
const LiveSettings = ({
|
||||
intl,
|
||||
onClose,
|
||||
}) {
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const courseId = useSelector(state => state.courseDetail.courseId);
|
||||
const availableProviders = useSelector((state) => state.live.appIds);
|
||||
@@ -57,10 +57,7 @@ function LiveSettings({
|
||||
is: (provider, tier) => provider === 'zoom' || (provider === 'big_blue_button' && tier === bbbPlanTypes.commercial),
|
||||
then: Yup.string().required(intl.formatMessage(messages.launchUrlRequired)),
|
||||
}),
|
||||
launchEmail: Yup.string().when('provider', {
|
||||
is: 'zoom',
|
||||
then: Yup.string().required(intl.formatMessage(messages.launchEmailRequired)),
|
||||
}),
|
||||
launchEmail: Yup.string(),
|
||||
};
|
||||
|
||||
const handleProviderChange = (providerId, setFieldValue, values) => {
|
||||
@@ -78,59 +75,55 @@ function LiveSettings({
|
||||
}, [courseId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppSettingsModal
|
||||
appId="live"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableLiveHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableLiveLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableLiveLink)}
|
||||
onClose={onClose}
|
||||
initialValues={liveConfiguration}
|
||||
validationSchema={validationSchema}
|
||||
onSettingsSave={handleSettingsSave}
|
||||
configureBeforeEnable
|
||||
enableReinitialize
|
||||
>
|
||||
{({ values, setFieldValue }) => (
|
||||
<>
|
||||
{(status === RequestStatus.IN_PROGRESS) ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<h4 className="my-3">{intl.formatMessage(messages.selectProvider)}</h4>
|
||||
<SelectableBox.Set
|
||||
type="checkbox"
|
||||
value={values.provider}
|
||||
onChange={(event) => handleProviderChange(event.target.value, setFieldValue, values)}
|
||||
name="provider"
|
||||
columns={3}
|
||||
className="mb-3"
|
||||
>
|
||||
{availableProviders.map((provider) => (
|
||||
<SelectableBox value={provider} type="checkbox" key={provider}>
|
||||
<div className="d-flex flex-column align-items-center">
|
||||
<Icon src={iconsSrc[`${camelCase(provider)}`]} alt={provider} />
|
||||
<span>{intl.formatMessage(messages[`appName-${camelCase(provider)}`])}</span>
|
||||
</div>
|
||||
</SelectableBox>
|
||||
))}
|
||||
</SelectableBox.Set>
|
||||
{values.provider === 'zoom' ? <ZoomSettings values={values} />
|
||||
: (
|
||||
<BBBSettings
|
||||
values={values}
|
||||
setFieldValue={setFieldValue}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<AppSettingsModal
|
||||
appId="live"
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
enableAppHelp={intl.formatMessage(messages.enableLiveHelp)}
|
||||
enableAppLabel={intl.formatMessage(messages.enableLiveLabel)}
|
||||
learnMoreText={intl.formatMessage(messages.enableLiveLink)}
|
||||
onClose={onClose}
|
||||
initialValues={liveConfiguration}
|
||||
validationSchema={validationSchema}
|
||||
onSettingsSave={handleSettingsSave}
|
||||
configureBeforeEnable
|
||||
enableReinitialize
|
||||
>
|
||||
{({ values, setFieldValue }) => (
|
||||
(status === RequestStatus.IN_PROGRESS) ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<h4 className="my-3">{intl.formatMessage(messages.selectProvider)}</h4>
|
||||
<SelectableBox.Set
|
||||
type="checkbox"
|
||||
value={values.provider}
|
||||
onChange={(event) => handleProviderChange(event.target.value, setFieldValue, values)}
|
||||
name="provider"
|
||||
columns={3}
|
||||
className="mb-3"
|
||||
>
|
||||
{availableProviders.map((provider) => (
|
||||
<SelectableBox value={provider} type="checkbox" key={provider}>
|
||||
<div className="d-flex flex-column align-items-center">
|
||||
<Icon src={iconsSrc[`${camelCase(provider)}`]} alt={provider} />
|
||||
<span>{intl.formatMessage(messages[`appName-${camelCase(provider)}`])}</span>
|
||||
</div>
|
||||
</SelectableBox>
|
||||
))}
|
||||
</SelectableBox.Set>
|
||||
{values.provider === 'zoom' ? <ZoomSettings values={values} />
|
||||
: (
|
||||
<BBBSettings
|
||||
values={values}
|
||||
setFieldValue={setFieldValue}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</AppSettingsModal>
|
||||
</>
|
||||
</AppSettingsModal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
LiveSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -6,16 +6,16 @@ import { providerNames } from './constants';
|
||||
import LiveCommonFields from './LiveCommonFields';
|
||||
import FormikControl from '../../generic/FormikControl';
|
||||
|
||||
function ZoomsSettings({
|
||||
const ZoomSettings = ({
|
||||
intl,
|
||||
values,
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{(!values.piiSharingEnable && (values.piiSharingEmail || values.piiSharingUsername)) ? (
|
||||
<p data-testid="request-pii-sharing">
|
||||
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
|
||||
</p>
|
||||
}) => (
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
<>
|
||||
{!values.piiSharingEnable ? (
|
||||
<p data-testid="request-pii-sharing">
|
||||
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{(values.piiSharingEmail || values.piiSharingUsername)
|
||||
@@ -33,11 +33,10 @@ function ZoomsSettings({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ZoomsSettings.propTypes = {
|
||||
ZoomSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
values: PropTypes.shape({
|
||||
consumerKey: PropTypes.string,
|
||||
@@ -51,4 +50,4 @@ ZoomsSettings.propTypes = {
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ZoomsSettings);
|
||||
export default injectIntl(ZoomSettings);
|
||||
|
||||
@@ -84,8 +84,8 @@ describe('Zoom Settings', () => {
|
||||
history.push(liveSettingsUrl);
|
||||
});
|
||||
|
||||
test('LTI fields are visible when pii sharing is enabled and email or username sharing required', async () => {
|
||||
await mockStore({ emailSharing: true });
|
||||
test('LTI fields are visible when pii sharing is enabled', async () => {
|
||||
await mockStore({ piiSharingAllowed: true });
|
||||
renderComponent();
|
||||
|
||||
const spinner = getByRole(container, 'status');
|
||||
@@ -107,9 +107,9 @@ describe('Zoom Settings', () => {
|
||||
});
|
||||
|
||||
test(
|
||||
'Only connect to support message is visible when pii sharing is disabled and email or username sharing is required',
|
||||
'Only connect to support message is visible when pii sharing is disabled',
|
||||
async () => {
|
||||
await mockStore({ emailSharing: true, piiSharingAllowed: false });
|
||||
await mockStore({ piiSharingAllowed: false });
|
||||
renderComponent();
|
||||
|
||||
const spinner = getByRole(container, 'status');
|
||||
@@ -133,7 +133,7 @@ describe('Zoom Settings', () => {
|
||||
|
||||
test('Provider Configuration should be displayed correctly', async () => {
|
||||
const apiDefaultResponse = generateLiveConfigurationApiResponse(true, true);
|
||||
await mockStore({ emailSharing: false, piiSharingAllowed: false });
|
||||
await mockStore({ piiSharingAllowed: true });
|
||||
renderComponent();
|
||||
|
||||
const spinner = getByRole(container, 'status');
|
||||
|
||||
@@ -36,11 +36,11 @@ export const initialState = {
|
||||
export const configurationProviders = (
|
||||
emailSharing,
|
||||
usernameSharing,
|
||||
activeProvider = 'zoom',
|
||||
activeProvider,
|
||||
hasFreeTier,
|
||||
) => ({
|
||||
providers: {
|
||||
active: activeProvider,
|
||||
active: activeProvider || 'zoom',
|
||||
available: {
|
||||
zoom: {
|
||||
features: [],
|
||||
@@ -65,7 +65,7 @@ export const generateLiveConfigurationApiResponse = (
|
||||
enabled,
|
||||
piiSharingAllowed,
|
||||
providerType = 'zoom',
|
||||
isFreeTier,
|
||||
isFreeTier = undefined,
|
||||
) => ({
|
||||
course_key: courseId,
|
||||
enabled,
|
||||
|
||||
@@ -160,9 +160,14 @@ const messages = defineMessages({
|
||||
|
||||
freePlanMessage: {
|
||||
id: 'authoring.live.freePlanMessage',
|
||||
defaultMessage: 'The free plan is pre-configured, and no additional configurations are required.',
|
||||
defaultMessage: 'The free plan is pre-configured, and no additional configurations are required. By selecting the free plan, you are agreeing to Blindside Networks',
|
||||
description: 'Tells user that free plans requires no additional configurations',
|
||||
},
|
||||
privacyPolicy: {
|
||||
id: 'authoring.live.privacyPolicy',
|
||||
defaultMessage: 'Privacy Policy.',
|
||||
description: 'The text of privacy policy hyperlink for free plan',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -17,6 +17,7 @@ const CoursePageShape = PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
enabled: PropTypes.bool.isRequired,
|
||||
legacyLink: PropTypes.string,
|
||||
allowedOperations: PropTypes.shape({
|
||||
enable: PropTypes.bool.isRequired,
|
||||
configure: PropTypes.bool.isRequired,
|
||||
@@ -25,13 +26,14 @@ const CoursePageShape = PropTypes.shape({
|
||||
|
||||
export { CoursePageShape };
|
||||
|
||||
function PageCard({
|
||||
const PageCard = ({
|
||||
intl,
|
||||
page,
|
||||
}) {
|
||||
}) => {
|
||||
const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
|
||||
const isDesktop = useIsDesktop();
|
||||
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
const SettingsButton = () => {
|
||||
if (page.legacyLink) {
|
||||
return (
|
||||
@@ -80,7 +82,7 @@ function PageCard({
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
PageCard.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -12,3 +12,11 @@
|
||||
-webkit-box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3) !important;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
[dir=rtl] {
|
||||
.desktop-card, .mobile-card {
|
||||
.pgn__card-header-actions .pgn__hyperlink .btn-icon {
|
||||
transform: scaleX(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,19 @@ import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { CardGrid } from '@edx/paragon';
|
||||
import PageCard, { CoursePageShape } from './PageCard';
|
||||
|
||||
function PageGrid({ pages }) {
|
||||
return (
|
||||
<CardGrid columnSizes={{
|
||||
const PageGrid = ({ pages }) => (
|
||||
<CardGrid columnSizes={{
|
||||
xs: 12,
|
||||
sm: 6,
|
||||
lg: 4,
|
||||
xl: 4,
|
||||
}}
|
||||
>
|
||||
{pages.map((page) => (
|
||||
<PageCard page={page} key={page.id} />
|
||||
>
|
||||
{pages.map((page) => (
|
||||
<PageCard page={page} key={page.id} />
|
||||
))}
|
||||
</CardGrid>
|
||||
</CardGrid>
|
||||
);
|
||||
}
|
||||
|
||||
PageGrid.propTypes = {
|
||||
pages: PropTypes.arrayOf(CoursePageShape.isRequired).isRequired,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import ExamsApiService from '../../data/services/ExamsApiService';
|
||||
import StudioApiService from '../../data/services/StudioApiService';
|
||||
import Loading from '../../generic/Loading';
|
||||
import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert';
|
||||
@@ -23,7 +24,7 @@ import { useIsMobile } from '../../utils';
|
||||
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
|
||||
import messages from './messages';
|
||||
|
||||
function ProctoringSettings({ intl, onClose }) {
|
||||
const ProctoringSettings = ({ intl, onClose }) => {
|
||||
const initialFormValues = {
|
||||
enableProctoredExams: false,
|
||||
proctoringProvider: false,
|
||||
@@ -36,7 +37,9 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [loadingConnectionError, setLoadingConnectionError] = useState(false);
|
||||
const [loadingPermissionError, setLoadingPermissionError] = useState(false);
|
||||
const [allowLtiProviders, setAllowLtiProviders] = useState(false);
|
||||
const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]);
|
||||
const [ltiProctoringProviders, setLtiProctoringProviders] = useState([]);
|
||||
const [courseStartDate, setCourseStartDate] = useState('');
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
@@ -57,7 +60,7 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
const proctoringEscalationEmailInputRef = useRef(null);
|
||||
const submitButtonState = submissionInProgress ? 'pending' : 'default';
|
||||
|
||||
function handleChange(event) {
|
||||
const handleChange = (event) => {
|
||||
const { target } = event;
|
||||
const value = target.type === 'checkbox' ? target.checked : target.value;
|
||||
const { name } = target;
|
||||
@@ -83,32 +86,49 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
} else {
|
||||
setFormValues({ ...formValues, [name]: value });
|
||||
}
|
||||
};
|
||||
|
||||
function isLtiProvider(provider) {
|
||||
return ltiProctoringProviders.some(p => p.name === provider);
|
||||
}
|
||||
|
||||
function setFocusToProctortrackEscalationEmailInput() {
|
||||
const setFocusToProctortrackEscalationEmailInput = () => {
|
||||
if (proctoringEscalationEmailInputRef && proctoringEscalationEmailInputRef.current) {
|
||||
proctoringEscalationEmailInputRef.current.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function postSettingsBackToServer() {
|
||||
const dataToPostBack = {
|
||||
const providerIsLti = isLtiProvider(formValues.proctoringProvider);
|
||||
const studioDataToPostBack = {
|
||||
proctored_exam_settings: {
|
||||
enable_proctored_exams: formValues.enableProctoredExams,
|
||||
proctoring_provider: formValues.proctoringProvider,
|
||||
// lti providers are managed outside edx-platform, lti_external indicates this
|
||||
proctoring_provider: providerIsLti ? 'lti_external' : formValues.proctoringProvider,
|
||||
create_zendesk_tickets: formValues.createZendeskTickets,
|
||||
},
|
||||
};
|
||||
if (isEdxStaff) {
|
||||
dataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut;
|
||||
studioDataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut;
|
||||
}
|
||||
|
||||
if (formValues.proctoringProvider === 'proctortrack') {
|
||||
dataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.proctortrackEscalationEmail === '' ? null : formValues.proctortrackEscalationEmail;
|
||||
studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.proctortrackEscalationEmail === '' ? null : formValues.proctortrackEscalationEmail;
|
||||
}
|
||||
|
||||
// only save back to exam service if necessary
|
||||
setSubmissionInProgress(true);
|
||||
StudioApiService.saveProctoredExamSettingsData(courseId, dataToPostBack).then(() => {
|
||||
const saveOperations = [StudioApiService.saveProctoredExamSettingsData(courseId, studioDataToPostBack)];
|
||||
if (allowLtiProviders && ExamsApiService.isAvailable()) {
|
||||
saveOperations.push(
|
||||
ExamsApiService.saveCourseExamConfiguration(
|
||||
courseId,
|
||||
{ provider: providerIsLti ? formValues.proctoringProvider : null },
|
||||
),
|
||||
);
|
||||
}
|
||||
Promise.all(saveOperations)
|
||||
.then(() => {
|
||||
setSaveSuccess(true);
|
||||
setSaveError(false);
|
||||
setSubmissionInProgress(false);
|
||||
@@ -119,7 +139,7 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit(event) {
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
if (
|
||||
formValues.proctoringProvider === 'proctortrack'
|
||||
@@ -133,7 +153,11 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
isValid: false,
|
||||
errors: {
|
||||
formProctortrackEscalationEmail: {
|
||||
dialogErrorMessage: (<Alert.Link onClick={setFocusToProctortrackEscalationEmailInput} href="#formProctortrackEscalationEmail" data-testid="proctorTrackEscalationEmailErrorLink">{errorMessage}</Alert.Link>),
|
||||
dialogErrorMessage: (
|
||||
<Alert.Link onClick={setFocusToProctortrackEscalationEmailInput} href="#formProctortrackEscalationEmail" data-testid="proctorTrackEscalationEmailErrorLink">
|
||||
{errorMessage}
|
||||
</Alert.Link>
|
||||
),
|
||||
inputErrorMessage: errorMessage,
|
||||
},
|
||||
},
|
||||
@@ -160,7 +184,7 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
errors,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function cannotEditProctoringProvider() {
|
||||
const currentDate = moment(moment()).format('YYYY-MM-DD[T]hh:mm:ss[Z]');
|
||||
@@ -178,6 +202,11 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
return markDisabled;
|
||||
}
|
||||
|
||||
function getProviderDisplayLabel(provider) {
|
||||
// if a display label exists for this provider return it
|
||||
return ltiProctoringProviders.find(p => p.name === provider)?.verbose_name || provider;
|
||||
}
|
||||
|
||||
function getProctoringProviderOptions(providers) {
|
||||
return providers.map(provider => (
|
||||
<option
|
||||
@@ -186,7 +215,7 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
disabled={isDisabledOption(provider)}
|
||||
data-testid={provider}
|
||||
>
|
||||
{provider}
|
||||
{getProviderDisplayLabel(provider)}
|
||||
</option>
|
||||
));
|
||||
}
|
||||
@@ -344,7 +373,7 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
)}
|
||||
|
||||
{/* CREATE ZENDESK TICKETS */}
|
||||
{ isEdxStaff && formValues.enableProctoredExams && (
|
||||
{ isEdxStaff && formValues.enableProctoredExams && !isLtiProvider(formValues.proctoringProvider) && (
|
||||
<fieldset aria-describedby="createZendeskTicketsText">
|
||||
<Form.Group controlId="formCreateZendeskTickets">
|
||||
<Form.Label as="legend" className="font-weight-bold">
|
||||
@@ -444,25 +473,53 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
StudioApiService.getProctoredExamSettingsData(courseId)
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
StudioApiService.getProctoredExamSettingsData(courseId),
|
||||
ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(),
|
||||
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders() : Promise.resolve(),
|
||||
])
|
||||
.then(
|
||||
response => {
|
||||
const proctoredExamSettings = response.data.proctored_exam_settings;
|
||||
([settingsResponse, examConfigResponse, ltiProvidersResponse]) => {
|
||||
const proctoredExamSettings = settingsResponse.data.proctored_exam_settings;
|
||||
setLoaded(true);
|
||||
setLoading(false);
|
||||
setSubmissionInProgress(false);
|
||||
setCourseStartDate(response.data.course_start_date);
|
||||
setCourseStartDate(settingsResponse.data.course_start_date);
|
||||
const isProctortrack = proctoredExamSettings.proctoring_provider === 'proctortrack';
|
||||
setShowProctortrackEscalationEmail(isProctortrack);
|
||||
setAvailableProctoringProviders(response.data.available_proctoring_providers);
|
||||
setAvailableProctoringProviders(settingsResponse.data.available_proctoring_providers);
|
||||
const proctoringEscalationEmail = proctoredExamSettings.proctoring_escalation_email;
|
||||
|
||||
// The list of providers returned by studio settings are the default behavior. If lti_external
|
||||
// is available as an option display the list of LTI providers returned by the exam service.
|
||||
// Setting 'lti_external' in studio indicates an LTI provider configured outside of edx-platform.
|
||||
// This option is not directly selectable.
|
||||
const proctoringProvidersStudio = settingsResponse.data.available_proctoring_providers;
|
||||
const proctoringProvidersLti = ltiProvidersResponse?.data || [];
|
||||
const enableLtiProviders = proctoringProvidersStudio.includes('lti_external');
|
||||
setAllowLtiProviders(enableLtiProviders);
|
||||
setLtiProctoringProviders(proctoringProvidersLti);
|
||||
// flatten provider objects and coalesce values to just the provider key
|
||||
let availableProviders = proctoringProvidersStudio.filter(value => value !== 'lti_external');
|
||||
if (enableLtiProviders) {
|
||||
availableProviders = proctoringProvidersLti.reduce(
|
||||
(result, provider) => [...result, provider.name],
|
||||
availableProviders,
|
||||
);
|
||||
}
|
||||
setAvailableProctoringProviders(availableProviders);
|
||||
|
||||
let selectedProvider;
|
||||
if (proctoredExamSettings.proctoring_provider === 'lti_external') {
|
||||
selectedProvider = examConfigResponse.data.provider;
|
||||
} else {
|
||||
selectedProvider = proctoredExamSettings.proctoring_provider;
|
||||
}
|
||||
setFormValues({
|
||||
...formValues,
|
||||
proctoringProvider: selectedProvider,
|
||||
enableProctoredExams: proctoredExamSettings.enable_proctored_exams,
|
||||
proctoringProvider: proctoredExamSettings.proctoring_provider,
|
||||
allowOptingOut: proctoredExamSettings.allow_proctoring_opt_out,
|
||||
createZendeskTickets: proctoredExamSettings.create_zendesk_tickets,
|
||||
// The backend API may return null for the proctoringEscalationEmail value, which is the default.
|
||||
@@ -473,7 +530,7 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
},
|
||||
).catch(
|
||||
error => {
|
||||
if (error.response.status === 403) {
|
||||
if (error.response?.status === 403) {
|
||||
setLoadingPermissionError(true);
|
||||
} else {
|
||||
setLoadingConnectionError(true);
|
||||
@@ -483,8 +540,7 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
setSubmissionInProgress(false);
|
||||
},
|
||||
);
|
||||
}, [],
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if ((saveSuccess || saveError) && !!saveStatusAlertRef.current) {
|
||||
@@ -545,7 +601,7 @@ function ProctoringSettings({ intl, onClose }) {
|
||||
</Form>
|
||||
</ModalDialog>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ProctoringSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -4,12 +4,13 @@ import {
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { initializeMockApp, mergeConfig } 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 StudioApiService from '../../data/services/StudioApiService';
|
||||
import ExamsApiService from '../../data/services/ExamsApiService';
|
||||
import initializeStore from '../../store';
|
||||
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
|
||||
import ProctoredExamSettings from './Settings';
|
||||
@@ -33,7 +34,19 @@ const intlWrapper = children => (
|
||||
let axiosMock;
|
||||
|
||||
describe('ProctoredExamSettings', () => {
|
||||
beforeEach(() => {
|
||||
function setupApp(isAdmin = true) {
|
||||
mergeConfig({
|
||||
EXAMS_BASE_URL: 'http://exams.testing.co',
|
||||
}, 'CourseAuthoringConfig');
|
||||
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: isAdmin,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore({
|
||||
models: {
|
||||
courseApps: {
|
||||
@@ -41,39 +54,47 @@ describe('ProctoredExamSettings', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
|
||||
).reply(200, [
|
||||
{
|
||||
name: 'test_lti',
|
||||
verbose_name: 'LTI Provider',
|
||||
},
|
||||
]);
|
||||
axiosMock.onGet(
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||
).reply(200, {
|
||||
provider: null,
|
||||
});
|
||||
|
||||
axiosMock.onGet(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, {
|
||||
proctored_exam_settings: {
|
||||
enable_proctored_exams: true,
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
proctoring_escalation_email: 'test@example.com',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
|
||||
course_start_date: '2070-01-01T00:00:00Z',
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
axiosMock.reset();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
setupApp();
|
||||
});
|
||||
|
||||
describe('Field dependencies', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onGet(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, {
|
||||
proctored_exam_settings: {
|
||||
enable_proctored_exams: true,
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
proctoring_escalation_email: 'test@example.com',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
|
||||
course_start_date: '2070-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
});
|
||||
|
||||
@@ -166,21 +187,23 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
|
||||
});
|
||||
|
||||
it('Hides unsupported fields when lti provider is selected', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation with invalid escalation email', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onGet(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, {
|
||||
@@ -399,83 +422,131 @@ describe('ProctoredExamSettings', () => {
|
||||
course_start_date: '2013-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
function setup(data, isAdmin) {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: isAdmin,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
function mockCourseData(data) {
|
||||
axiosMock.onGet(StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId)).reply(200, data);
|
||||
}
|
||||
|
||||
it('Disables irrelevant proctoring provider fields when user is not an administrator and it is after start date', async () => {
|
||||
setup(mockGetPastCourseData, false);
|
||||
const isAdmin = false;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetPastCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(true);
|
||||
});
|
||||
|
||||
it('Enables all proctoring provider options if user is not an administrator and it is before start date', async () => {
|
||||
setup(mockGetFutureCourseData, false);
|
||||
const isAdmin = false;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
|
||||
it('Enables all proctoring provider options if user administrator and it is after start date', async () => {
|
||||
setup(mockGetPastCourseData, true);
|
||||
const isAdmin = true;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetPastCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
|
||||
it('Enables all proctoring provider options if user administrator and it is before start date', async () => {
|
||||
setup(mockGetFutureCourseData, true);
|
||||
const isAdmin = true;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
|
||||
it('Does not include lti_external as a selectable option', async () => {
|
||||
const courseData = {
|
||||
...mockGetFutureCourseData,
|
||||
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
|
||||
};
|
||||
mockCourseData(courseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
expect(screen.queryByTestId('lti_external')).toBeNull();
|
||||
});
|
||||
|
||||
it('Includes lti proctoring provider options when lti_external is allowed by studio', async () => {
|
||||
const courseData = {
|
||||
...mockGetFutureCourseData,
|
||||
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
|
||||
};
|
||||
mockCourseData(courseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
const providerOption = screen.getByTestId('test_lti');
|
||||
// as as admin the provider should not be disabled
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
|
||||
it('Does not include lti provider options when lti_external is not available in studio', async () => {
|
||||
const isAdmin = true;
|
||||
setupApp(isAdmin);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
|
||||
const providerOption = screen.queryByTestId('test_lti');
|
||||
expect(providerOption).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Does not request lti provider options if there is no exam service url configuration', async () => {
|
||||
mergeConfig({
|
||||
EXAMS_BASE_URL: null,
|
||||
}, 'CourseAuthoringConfig');
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
// only outgoing request should be for studio settings
|
||||
expect(axiosMock.history.get.length).toBe(1);
|
||||
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
|
||||
});
|
||||
|
||||
it('Selected LTI proctoring provider is shown on page load', async () => {
|
||||
const courseData = { ...mockGetFutureCourseData };
|
||||
courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc'];
|
||||
courseData.proctored_exam_settings.proctoring_provider = 'lti_external';
|
||||
mockCourseData(courseData);
|
||||
axiosMock.onGet(
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||
).reply(200, {
|
||||
provider: 'test_lti',
|
||||
});
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
await waitFor(() => {
|
||||
screen.getByText('Proctoring provider');
|
||||
});
|
||||
|
||||
// make sure test_lti is the selected provider
|
||||
expect(screen.getByDisplayValue('LTI Provider')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Toggles field visibility based on user permissions', () => {
|
||||
function setup(isAdmin) {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: isAdmin,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId)).reply(200, {
|
||||
proctored_exam_settings: {
|
||||
enable_proctored_exams: true,
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
proctoring_escalation_email: 'test@example.com',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
|
||||
course_start_date: '2070-01-01T00:00:00Z',
|
||||
});
|
||||
}
|
||||
|
||||
it('Hides opting out and zendesk tickets for non edX staff', async () => {
|
||||
setup(false);
|
||||
setupApp(false);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
expect(screen.queryByTestId('allowOptingOutYes')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||
});
|
||||
|
||||
it('Shows opting out and zendesk tickets for edX staff', async () => {
|
||||
setup(true);
|
||||
setupApp(true);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
|
||||
@@ -483,18 +554,6 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
describe('Connection states', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('Shows the spinner before the connection is complete', async () => {
|
||||
await act(async () => {
|
||||
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
|
||||
@@ -504,7 +563,7 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('Show connection error message when we suffer server side error', async () => {
|
||||
it('Show connection error message when we suffer studio server side error', async () => {
|
||||
axiosMock.onGet(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(500);
|
||||
@@ -516,6 +575,18 @@ describe('ProctoredExamSettings', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('Show connection error message when we suffer edx-exams server side error', async () => {
|
||||
axiosMock.onGet(
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
|
||||
).reply(500);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const connectionError = screen.getByTestId('connectionErrorAlert');
|
||||
expect(connectionError.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error when loading this page.'),
|
||||
);
|
||||
});
|
||||
|
||||
it('Show permission error message when user do not have enough permission', async () => {
|
||||
axiosMock.onGet(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
@@ -530,34 +601,16 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
describe('Save settings', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient(), { onNoMatch: 'throwException' });
|
||||
axiosMock.onGet(StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId)).reply(200, {
|
||||
proctored_exam_settings: {
|
||||
enable_proctored_exams: true,
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
proctoring_escalation_email: 'test@example.com',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
|
||||
});
|
||||
});
|
||||
|
||||
it('Disable button while submitting', async () => {
|
||||
beforeEach(async () => {
|
||||
axiosMock.onPost(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, 'success');
|
||||
axiosMock.onPatch(
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||
).reply(200, 'success');
|
||||
});
|
||||
|
||||
it('Disable button while submitting', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
let submitButton = screen.getByTestId('submissionButton');
|
||||
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
|
||||
@@ -570,10 +623,6 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
|
||||
axiosMock.onPost(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, 'success');
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the provider to proctortrack and set the email
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
@@ -609,10 +658,6 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
|
||||
axiosMock.onPost(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, 'success');
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
|
||||
// make sure we have not selected proctortrack as the proctoring provider
|
||||
@@ -639,7 +684,112 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it('Makes API call generated error', async () => {
|
||||
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the provider to proctortrack and set the email
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
});
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
// update exam service config
|
||||
expect(axiosMock.history.patch.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
||||
provider: 'test_lti',
|
||||
});
|
||||
|
||||
// update studio settings
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
||||
proctored_exam_settings: {
|
||||
enable_proctored_exams: true,
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'lti_external',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
});
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
// update exam service config
|
||||
expect(axiosMock.history.patch.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
||||
provider: null,
|
||||
});
|
||||
expect(axiosMock.history.patch.length).toBe(1);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
||||
proctored_exam_settings: {
|
||||
enable_proctored_exams: true,
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
});
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it('Does not update exam service if lti is not enabled in studio', async () => {
|
||||
axiosMock.onGet(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, {
|
||||
proctored_exam_settings: {
|
||||
enable_proctored_exams: true,
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
proctoring_escalation_email: 'test@example.com',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
|
||||
course_start_date: '2070-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
// does not update exam service config
|
||||
expect(axiosMock.history.patch.length).toBe(0);
|
||||
// does update studio
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
||||
proctored_exam_settings: {
|
||||
enable_proctored_exams: true,
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
});
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it('Makes studio API call generated error', async () => {
|
||||
axiosMock.onPost(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(500);
|
||||
@@ -657,11 +807,29 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it('Makes exams API call generated error', async () => {
|
||||
axiosMock.onPatch(
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||
).reply(500, 'error');
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
const errorAlert = screen.getByTestId('saveError');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
it('Manages focus correctly after different save statuses', async () => {
|
||||
// first make a call that will cause a save error
|
||||
axiosMock.onPost(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).replyOnce(500);
|
||||
).reply(500);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
@@ -678,7 +846,7 @@ describe('ProctoredExamSettings', () => {
|
||||
// now make a call that will allow for a successful save
|
||||
axiosMock.onPost(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).replyOnce(200, 'success');
|
||||
).reply(200, 'success');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
@@ -693,28 +861,8 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
it('Include Zendesk ticket in post request if user is not an admin', async () => {
|
||||
// use non-admin user for test
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 4,
|
||||
username: 'abc1234',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient(), { onNoMatch: 'throwException' });
|
||||
axiosMock.onGet(StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId)).reply(200, {
|
||||
proctored_exam_settings: {
|
||||
enable_proctored_exams: true,
|
||||
allow_proctoring_opt_out: false,
|
||||
proctoring_provider: 'mockproc',
|
||||
proctoring_escalation_email: 'test@example.com',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
|
||||
});
|
||||
axiosMock.onPost(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, 'success');
|
||||
const isAdmin = false;
|
||||
setupApp(isAdmin);
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the proctoring provider
|
||||
|
||||
@@ -8,9 +8,9 @@ import { useAppSetting } from '../../utils';
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
function ProgressSettings({ intl, onClose }) {
|
||||
const ProgressSettings = ({ intl, onClose }) => {
|
||||
const [disableProgressGraph, saveSetting] = useAppSetting('disableProgressGraph');
|
||||
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toLowerCase() === 'true';
|
||||
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toString().toLowerCase() === 'true';
|
||||
|
||||
const handleSettingsSave = (values) => {
|
||||
if (showProgressGraphSetting) { saveSetting(!values.enableProgressGraph); }
|
||||
@@ -45,7 +45,7 @@ function ProgressSettings({ intl, onClose }) {
|
||||
}
|
||||
</AppSettingsModal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ProgressSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -24,9 +24,9 @@ const TeamTypeNameMessage = {
|
||||
},
|
||||
};
|
||||
|
||||
function GroupEditor({
|
||||
const GroupEditor = ({
|
||||
intl, group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
|
||||
}) {
|
||||
}) => {
|
||||
const [isDeleting, setDeleting] = useState(false);
|
||||
const [isOpen, setOpen] = useState(group.id === null);
|
||||
const initiateDeletion = () => setDeleting(true);
|
||||
@@ -133,7 +133,7 @@ function GroupEditor({
|
||||
)}
|
||||
</TransitionReplace>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const groupShape = PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
|
||||
@@ -16,10 +16,10 @@ import messages from './messages';
|
||||
|
||||
setupYupExtensions();
|
||||
|
||||
function TeamSettings({
|
||||
const TeamSettings = ({
|
||||
intl,
|
||||
onClose,
|
||||
}) {
|
||||
}) => {
|
||||
const [teamsConfiguration, saveSettings] = useAppSetting('teamsConfiguration');
|
||||
const blankNewGroup = {
|
||||
name: '',
|
||||
@@ -161,7 +161,7 @@ function TeamSettings({
|
||||
}
|
||||
</AppSettingsModal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
TeamSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useAppSetting } from '../../utils';
|
||||
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
|
||||
import messages from './messages';
|
||||
|
||||
function WikiSettings({ intl, onClose }) {
|
||||
const WikiSettings = ({ intl, onClose }) => {
|
||||
const [enablePublicWiki, saveSetting] = useAppSetting('allowPublicWikiAccess');
|
||||
const handleSettingsSave = (values) => saveSetting(values.enablePublicWiki);
|
||||
|
||||
@@ -39,7 +39,7 @@ function WikiSettings({ intl, onClose }) {
|
||||
}
|
||||
</AppSettingsModal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
WikiSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
fetchExamSettingsSuccess,
|
||||
} from './data/thunks';
|
||||
|
||||
function ProctoredExamSettings({ courseId, intl }) {
|
||||
const ProctoredExamSettings = ({ courseId, intl }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
@@ -55,9 +55,9 @@ function ProctoredExamSettings({ courseId, intl }) {
|
||||
const saveStatusAlertRef = React.createRef();
|
||||
const proctoringEscalationEmailInputRef = useRef(null);
|
||||
|
||||
function onEnableProctoredExamsChange(event) {
|
||||
const onEnableProctoredExamsChange = (event) => {
|
||||
setEnableProctoredExams(event.target.checked);
|
||||
}
|
||||
};
|
||||
|
||||
function onAllowOptingOutChange(value) {
|
||||
setAllowOptingOut(value);
|
||||
@@ -67,7 +67,7 @@ function ProctoredExamSettings({ courseId, intl }) {
|
||||
setCreateZendeskTickets(value);
|
||||
}
|
||||
|
||||
function onProctoringProviderChange(event) {
|
||||
const onProctoringProviderChange = (event) => {
|
||||
const provider = event.target.value;
|
||||
setProctoringProvider(provider);
|
||||
|
||||
@@ -80,17 +80,17 @@ function ProctoredExamSettings({ courseId, intl }) {
|
||||
}
|
||||
setShowProctortrackEscalationEmail(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function onProctortrackEscalationEmailChange(event) {
|
||||
const onProctortrackEscalationEmailChange = (event) => {
|
||||
setProctortrackEscalationEmail(event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
function setFocusToProctortrackEscalationEmailInput() {
|
||||
const setFocusToProctortrackEscalationEmailInput = () => {
|
||||
if (proctoringEscalationEmailInputRef && proctoringEscalationEmailInputRef.current) {
|
||||
proctoringEscalationEmailInputRef.current.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function isLtiProvider(provider) {
|
||||
return ltiProctoringProviders.some(p => p.name === provider);
|
||||
@@ -120,9 +120,7 @@ function ProctoredExamSettings({ courseId, intl }) {
|
||||
const saveOperations = [StudioApiService.saveProctoredExamSettingsData(courseId, studioDataToPostBack)];
|
||||
if (allowLtiProviders && ExamsApiService.isAvailable()) {
|
||||
saveOperations.push(
|
||||
ExamsApiService.saveCourseExamConfiguration(
|
||||
courseId, { provider: providerIsLti ? proctoringProvider : null },
|
||||
),
|
||||
ExamsApiService.saveCourseExamConfiguration(courseId, { provider: providerIsLti ? proctoringProvider : null }),
|
||||
);
|
||||
}
|
||||
Promise.all(saveOperations)
|
||||
@@ -137,7 +135,7 @@ function ProctoredExamSettings({ courseId, intl }) {
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit(event) {
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
if (proctoringProvider === 'proctortrack' && !EmailValidator.validate(proctortrackEscalationEmail) && !(proctortrackEscalationEmail === '' && !enableProctoredExams)) {
|
||||
if (proctortrackEscalationEmail === '') {
|
||||
@@ -174,7 +172,7 @@ function ProctoredExamSettings({ courseId, intl }) {
|
||||
errors,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function cannotEditProctoringProvider() {
|
||||
const currentDate = moment(moment()).format('YYYY-MM-DD[T]hh:mm:ss[Z]');
|
||||
@@ -492,8 +490,7 @@ function ProctoredExamSettings({ courseId, intl }) {
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
useEffect(() => {
|
||||
dispatch(fetchExamSettingsPending(courseId));
|
||||
|
||||
Promise.all([
|
||||
@@ -519,14 +516,19 @@ function ProctoredExamSettings({ courseId, intl }) {
|
||||
// This option is not directly selectable.
|
||||
const proctoringProvidersStudio = settingsResponse.data.available_proctoring_providers;
|
||||
const proctoringProvidersLti = ltiProvidersResponse?.data || [];
|
||||
setAllowLtiProviders(proctoringProvidersStudio.includes('lti_external'));
|
||||
const enableLtiProviders = proctoringProvidersStudio.includes('lti_external');
|
||||
setAllowLtiProviders(enableLtiProviders);
|
||||
setLtiProctoringProviders(proctoringProvidersLti);
|
||||
// flatten provider objects and coalesce values to just the provider key
|
||||
setAvailableProctoringProviders(
|
||||
proctoringProvidersLti.reduce((result, provider) => [...result, provider.name], []).concat(
|
||||
proctoringProvidersStudio.filter(value => value !== 'lti_external'),
|
||||
),
|
||||
);
|
||||
let availableProviders = proctoringProvidersStudio.filter(value => value !== 'lti_external');
|
||||
if (enableLtiProviders) {
|
||||
availableProviders = proctoringProvidersLti.reduce(
|
||||
(result, provider) => [...result, provider.name],
|
||||
availableProviders,
|
||||
);
|
||||
}
|
||||
setAvailableProctoringProviders(availableProviders);
|
||||
|
||||
if (proctoredExamSettings.proctoring_provider === 'lti_external') {
|
||||
setProctoringProvider(examConfigResponse.data.provider);
|
||||
} else {
|
||||
@@ -556,8 +558,7 @@ function ProctoredExamSettings({ courseId, intl }) {
|
||||
dispatch(fetchExamSettingsFailure(courseId));
|
||||
},
|
||||
);
|
||||
}, [],
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if ((saveSuccess || saveError) && !!saveStatusAlertRef.current) {
|
||||
@@ -583,7 +584,7 @@ function ProctoredExamSettings({ courseId, intl }) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ProctoredExamSettings.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
|
||||
@@ -4,12 +4,12 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { AvatarIcon } from './Icons';
|
||||
|
||||
function Avatar({
|
||||
const Avatar = ({
|
||||
size,
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}) {
|
||||
}) => {
|
||||
const avatar = src ? (
|
||||
<img className="d-block w-100 h-100" src={src} alt={alt} />
|
||||
) : (
|
||||
@@ -24,7 +24,7 @@ function Avatar({
|
||||
{avatar}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Avatar.propTypes = {
|
||||
src: PropTypes.string,
|
||||
|
||||
@@ -22,9 +22,9 @@ ensureConfig([
|
||||
'LOGO_URL',
|
||||
], 'Header component');
|
||||
|
||||
function Header({
|
||||
const Header = ({
|
||||
courseId, courseNumber, courseOrg, courseTitle, intl,
|
||||
}) {
|
||||
}) => {
|
||||
const { authenticatedUser, config } = useContext(AppContext);
|
||||
|
||||
const mainMenu = [
|
||||
@@ -176,7 +176,7 @@ function Header({
|
||||
</Responsive>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Header.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react/jsx-no-constructed-context-values */
|
||||
// This file was copied from edx/frontend-component-header-edx.
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
@@ -40,14 +41,16 @@ describe('<Header />', () => {
|
||||
}
|
||||
|
||||
it('renders desktop header correctly with API call', async () => {
|
||||
const component = createComponent(1280, (
|
||||
<Header
|
||||
courseId="course-v1:edX+DemoX+Demo_Course"
|
||||
courseNumber="DemoX"
|
||||
courseOrg="edX"
|
||||
courseTitle="Demonstration Course"
|
||||
/>
|
||||
));
|
||||
const component = createComponent(
|
||||
1280, (
|
||||
<Header
|
||||
courseId="course-v1:edX+DemoX+Demo_Course"
|
||||
courseNumber="DemoX"
|
||||
courseOrg="edX"
|
||||
courseTitle="Demonstration Course"
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
render(component);
|
||||
expect(screen.getByTestId('course-org-number').textContent).toEqual(expect.stringContaining('edX DemoX'));
|
||||
@@ -55,42 +58,48 @@ describe('<Header />', () => {
|
||||
});
|
||||
|
||||
it('renders mobile header correctly with API call', async () => {
|
||||
const component = createComponent(500, (
|
||||
<Header
|
||||
courseId="course-v1:edX+DemoX+Demo_Course"
|
||||
courseNumber="DemoX"
|
||||
courseOrg="edX"
|
||||
courseTitle="Demonstration Course"
|
||||
/>
|
||||
));
|
||||
const component = createComponent(
|
||||
500, (
|
||||
<Header
|
||||
courseId="course-v1:edX+DemoX+Demo_Course"
|
||||
courseNumber="DemoX"
|
||||
courseOrg="edX"
|
||||
courseTitle="Demonstration Course"
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
render(component);
|
||||
expect(screen.getByTestId('edx-header-logo'));
|
||||
});
|
||||
|
||||
it('renders desktop header correctly with bad API call', async () => {
|
||||
const component = createComponent(1280, (
|
||||
<Header
|
||||
courseId="course-v1:edX+DemoX+Demo_Course"
|
||||
courseNumber={null}
|
||||
courseOrg={null}
|
||||
courseTitle="course-v1:edX+DemoX+Demo_Course"
|
||||
/>
|
||||
));
|
||||
const component = createComponent(
|
||||
1280, (
|
||||
<Header
|
||||
courseId="course-v1:edX+DemoX+Demo_Course"
|
||||
courseNumber={null}
|
||||
courseOrg={null}
|
||||
courseTitle="course-v1:edX+DemoX+Demo_Course"
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
render(component);
|
||||
expect(screen.getByTestId('course-title').textContent).toEqual(expect.stringContaining('course-v1:edX+DemoX+Demo_Course'));
|
||||
});
|
||||
|
||||
it('renders mobile header correctly with bad API call', async () => {
|
||||
const component = createComponent(500, (
|
||||
<Header
|
||||
courseId="course-v1:edX+DemoX+Demo_Course"
|
||||
courseNumber={null}
|
||||
courseOrg={null}
|
||||
courseTitle="course-v1:edX+DemoX+Demo_Course"
|
||||
/>
|
||||
));
|
||||
const component = createComponent(
|
||||
500, (
|
||||
<Header
|
||||
courseId="course-v1:edX+DemoX+Demo_Course"
|
||||
courseNumber={null}
|
||||
courseOrg={null}
|
||||
courseTitle="course-v1:edX+DemoX+Demo_Course"
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
render(component);
|
||||
expect(screen.getByTestId('edx-header-logo'));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file was copied from edx/frontend-component-header-edx.
|
||||
import React from 'react';
|
||||
|
||||
export const MenuIcon = props => (
|
||||
export const MenuIcon = (props) => (
|
||||
<svg
|
||||
width="24px"
|
||||
height="24px"
|
||||
@@ -13,9 +13,9 @@ export const MenuIcon = props => (
|
||||
<rect fill="currentColor" x="2" y="11" width="20" height="2" />
|
||||
<rect fill="currentColor" x="2" y="17" width="20" height="2" />
|
||||
</svg>
|
||||
);
|
||||
);
|
||||
|
||||
export const AvatarIcon = props => (
|
||||
export const AvatarIcon = (props) => (
|
||||
<svg
|
||||
width="24px"
|
||||
height="24px"
|
||||
@@ -28,9 +28,9 @@ export const AvatarIcon = props => (
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
);
|
||||
|
||||
export const CaretIcon = props => (
|
||||
export const CaretIcon = (props) => (
|
||||
<svg
|
||||
width="16px"
|
||||
height="16px"
|
||||
@@ -44,4 +44,4 @@ export const CaretIcon = props => (
|
||||
transform="translate(8.000000, 7.000000) rotate(-45.000000) translate(-8.000000, -7.000000) "
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -2,29 +2,25 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function Logo({ src, alt, ...attributes }) {
|
||||
return (
|
||||
<img src={src} alt={alt} {...attributes} />
|
||||
const Logo = ({ src, alt, ...attributes }) => (
|
||||
<img src={src} alt={alt} {...attributes} />
|
||||
);
|
||||
}
|
||||
|
||||
Logo.propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function LinkedLogo({
|
||||
const LinkedLogo = ({
|
||||
href,
|
||||
src,
|
||||
alt,
|
||||
...attributes
|
||||
}) {
|
||||
return (
|
||||
<a href={href} {...attributes}>
|
||||
<img className="d-block" src={src} alt={alt} />
|
||||
</a>
|
||||
}) => (
|
||||
<a href={href} {...attributes}>
|
||||
<img className="d-block" src={src} alt={alt} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
LinkedLogo.propTypes = {
|
||||
href: PropTypes.string.isRequired,
|
||||
|
||||
@@ -3,12 +3,10 @@ import React from 'react';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function MenuTrigger({ tag, className, ...attributes }) {
|
||||
return React.createElement(tag, {
|
||||
const MenuTrigger = ({ tag, className, ...attributes }) => React.createElement(tag, {
|
||||
className: `menu-trigger ${className}`,
|
||||
...attributes,
|
||||
});
|
||||
}
|
||||
MenuTrigger.propTypes = {
|
||||
tag: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
@@ -19,12 +17,10 @@ MenuTrigger.defaultProps = {
|
||||
};
|
||||
const MenuTriggerType = <MenuTrigger />.type;
|
||||
|
||||
function MenuContent({ tag, className, ...attributes }) {
|
||||
return React.createElement(tag, {
|
||||
const MenuContent = ({ tag, className, ...attributes }) => React.createElement(tag, {
|
||||
className: ['menu-content', className].join(' '),
|
||||
...attributes,
|
||||
});
|
||||
}
|
||||
MenuContent.propTypes = {
|
||||
tag: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
|
||||
Reference in New Issue
Block a user