Compare commits

..

84 Commits

Author SHA1 Message Date
Maria Grimaldi
7424b60a90 fix: cast progress graph configuration to string (#495) (#516)
(cherry picked from commit 51e5e7126c)
2023-06-12 11:35:48 -04:00
Mashal Malik
1c0e6fd4b5 feat: upgraded to node v18, added .nvmrc and updated workflows (#464)
* feat: upgraded to node v18, added .nvmrc and updated workflows

* feat: upfate validate workflow

* feat: update validate workflow

* fix: update lock file

* refactor: update validate file

* build: update pkg

* refactor: updated packages

* build: updated frontend-build, frontend-platform, component-footer & component-header packages

* refactor: updated workflow

* refactor: updated workflow

* refactor: updated workflow

* build: update commit file

* build: update lock file

* refactor: update workflow

* refactor: update workflow

* refactor: update workflow

* refactor: update workflow

* build: update pkg

* build: update pkgs

* build: update lock file

---------

Co-authored-by: Bilal Qamar <59555732+BilalQamar95@users.noreply.github.com>
2023-06-09 09:21:59 +02:00
Dmytro
bc3faa4105 fix: disable invalid link Video Uploads for Palm (#513) 2023-06-08 16:14:35 -04:00
connorhaugh
a94942a36e Feat update flcc for cat 2 part deux (#473)
* feat: update flcc for cat-2

* feat: updte flcc for cat-2 part deux
2023-04-10 15:55:59 -04:00
connorhaugh
ab7c51994c feat: update flcc for cat-2 (#472) 2023-04-10 10:58:59 -04:00
Raymond Zhou
67967a92cf feat: update FLCC to 1.129.0 (#471) 2023-04-06 14:12:09 -04:00
connorhaugh
6efa8c5356 feat: upgrade flcc to 125 (#470) 2023-04-05 10:24:15 -04:00
Kristin Aoki
c28669f5b2 feat: upgrade flcc package to 1.126.1 (#469) 2023-04-04 09:54:05 -04:00
kenclary
270f4a8a12 chore: upgrade frontend-lib-content-components to 1.124.0. (#468) 2023-03-27 13:57:44 -04:00
Kristin Aoki
641a169e6f feat: upgrade flcc to 1.11.8.0 (#466) 2023-03-22 14:52:22 -04:00
kenclary
25e254bbfb chore: update frontend-lib-content-components to 1.117.1. (#465) 2023-03-16 14:46:56 -04:00
Jenkins
af0ddf532a chore(i18n): update translations 2023-03-12 17:32:39 -04:00
kenclary
eaf76c8dee chore: update frontend-lib-content-components to 1.113.0. (#459) 2023-03-10 10:51:50 -05:00
Ihor Romaniuk
5c0ca7b706 feat: replace hardcoded edx string with site_name from configs (#425)
* feat: replace hardcoded edx string with site_name from configs

* feat: add ability to obtain site name dynamically

* fix: localize overriding createPortal method
2023-03-09 13:38:22 -05:00
Mashal Malik
530b247c33 refactor: remove unused tranisfex v2 url (#457) 2023-03-06 12:18:02 +05:00
Ihor Romaniuk
a5bc86e948 feat: replace hardcoded logo with logo from configs (#426) 2023-03-03 08:29:12 -05:00
connorhaugh
9910937269 feat: flcc 1.109.2 (#456) 2023-03-02 10:48:29 -05:00
connorhaugh
1344c289df feat: update flcc (#453) 2023-02-24 11:41:11 -05:00
Feanil Patel
7f4111c12c Update standard workflow files. (#452)
* build: Creating a missing workflow file `self-assign-issue.yml`.

The .github/workflows/self-assign-issue.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.

* build: Creating a missing workflow file `add-remove-label-on-comment.yml`.

The .github/workflows/add-remove-label-on-comment.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.

* build: Updating a missing workflow file `add-depr-ticket-to-depr-board.yml`.

The .github/workflows/add-depr-ticket-to-depr-board.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-24 10:44:48 -05:00
connorhaugh
105fdea8ef feat: upgrade FLCC to 1.101.3 (#451) 2023-02-15 08:41:05 -05:00
connorhaugh
9d91e3f242 feat: update flcc to 1.99.3 (#449) 2023-02-13 12:58:00 -05:00
connorhaugh
fdcb3a5e7f feat: update FLCC to 1.99.0 (#447) 2023-02-10 10:22:48 -05:00
Bilal Qamar
86974b76a9 Fixed broken "Pages and Resources" page (#446)
* fix: updated AppHeader call

* fix: AppHeader function signature changed to react component
2023-02-09 06:45:37 -05:00
Kristin Aoki
835915750c feat: update frontend-lib-content-component 1.95.0 (#445) 2023-02-07 16:13:51 -05:00
Jenkins
fe8a125d1a chore(i18n): update translations 2023-02-05 16:32:36 -05:00
Kristin Aoki
f82e572ad2 feat: update frontend-lib-content-components (#439) 2023-02-01 15:46:13 -05:00
Kristin Aoki
8aa03496fb feat: update frontend-content-components & paragon (#438) 2023-01-31 15:21:13 -05:00
Muhammad Abdullah Waheed
3c2c347bb9 Automate Browserlist DB Update (#357)
* feat: added cron github action to auto update brwoserlist DB periodically

* refactor: used a shared script to update broswerslist DB, create PR and automerge it
2023-01-31 17:40:26 +05:00
Jenkins
0d166288cc chore(i18n): update translations 2023-01-29 16:32:35 -05:00
Bilal Qamar
f8954ef870 refactor: upgraded frontend-build version to v12
PR #322
2023-01-26 09:02:14 -03:00
Muhammad Adeel Tajamul
66afd4ddac fix: updated openedx discussion provider help text (#431)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2023-01-25 07:42:29 +05:00
Kristin Aoki
a99eb8a44a feat: upgrade frontend-lib-content-components (#436) 2023-01-24 13:51:06 -05:00
kenclary
b2981318b0 chore: update version of frontend-lib-content-components to 1.85.0. (#435) 2023-01-24 10:16:03 -05:00
Jenkins
5142f3afd4 chore(i18n): update translations 2023-01-22 16:32:35 -05:00
Raymond Zhou
b7b3601337 feat: update FLCC to 1.81.0 (#432) 2023-01-20 11:55:58 -05:00
Kristin Aoki
50e5ca86c6 feat: upgrade paragon and lib-content-components (#430) 2023-01-17 16:26:11 -05:00
connorhaugh
fe9a9a37e7 feat: upgrade to 1.76.0 (#429) 2023-01-12 15:43:22 -05:00
connorhaugh
1c5ab42ea6 feat: update flcc to 1.74.0 (#428) 2023-01-12 10:53:46 -05:00
Kristin Aoki
74fcbe426d feat: upgrade lib-content-components to 1.73.0 (#419) 2023-01-06 10:41:27 -05:00
Mehak Nasir
6a65826fd5 fix: group at subsection field added in redux state 2023-01-05 14:31:44 +05:00
Jesper Hodge
ad47bfacd4 docs: add fix for m1 macs to readme (#421) 2023-01-03 16:32:54 -05:00
Jenkins
9e9bac997b chore(i18n): update translations 2022-12-25 16:32:50 -05:00
connorhaugh
014fbeac71 feat: release select type page (#418) 2022-12-23 09:22:14 -05:00
connorhaugh
0b214faeca feat: add problem editor to FLCC (#417) 2022-12-20 16:14:43 -05:00
Zachary Hancock
abff65a11a fix: LTI providers should not be shown unless enabled (#416) 2022-12-19 10:46:04 -05:00
Adolfo R. Brandes
2f6eed237a fix: Editors should support runtime configuration
Fetching settings directly via `process.env` circumvents the runtime
configuration mechanism.  Change the editor page to use `getConfig()`
instead.
2022-12-13 23:55:36 +00:00
Kristin Aoki
7253c9bba3 feat: upgrade lib-content-components 1.69.1 (#411) 2022-12-13 09:27:22 -05:00
Jenkins
a84d3c09e8 chore(i18n): update translations 2022-12-11 16:27:30 -05:00
Raymond Zhou
f5e1f1cf6b feat: FLCC v1.68.0 (#407) 2022-12-09 11:37:31 -05:00
ayesha waris
8096a389da style: confirmation modal added for in-context discussion toggle (#404)
* style: confirmation modal added for in-context discussion toggle

* refactor: removed duplicate message id

* refactor: function moved to on line direct call
2022-12-09 18:40:21 +05:00
kenclary
1a21850fc4 feat: upgrade lib-content-components to 1.66.3. (#405) 2022-12-07 11:58:26 -05:00
Kristin Aoki
efae5ecd4b feat: upgrade ib-content-components to 1.66.0 (#403) 2022-12-05 16:59:49 -05:00
Jenkins
1e043325d6 chore(i18n): update translations 2022-12-04 16:32:30 -05:00
Raymond Zhou
0bfce5594d feat: update paragon to v20.21.0 (#399) 2022-12-02 11:12:25 -05:00
connorhaugh
dbe2787785 feat: update flcc to 1.63.1 (#400) 2022-12-02 10:47:53 -05:00
connorhaugh
20e98319af feat: remove default background color for editors (#398) 2022-12-02 10:23:07 -05:00
Abdullah Waheed
2063049747 feat: added new translations in Makefile and updated all the translations 2022-11-30 13:30:22 +00:00
edX requirements bot
a7def9ce25 fix: -t flag added in pull translation command (#393) 2022-11-30 16:44:21 +05:00
Kristin Aoki
2418207149 feat: bump lib-content-componets to 1.61.0 (#397) 2022-11-29 16:34:42 -05:00
Kristin Aoki
5da5967e97 feat: update lib-content-componets to 1.60.1 (#395) 2022-11-29 15:10:40 -05:00
Jenkins
45b2bf5b13 chore(i18n): update translations 2022-11-27 16:32:27 -05:00
ayesha waris
7527f6c764 Style: Toggle remains Disabled and Link to instructor dashboard show on Disabled Cohorts (#375)
* style: on disabled cohorts toggle remains disabled and link to instructor dashboard shows

* fix: test cases fixed

Co-authored-by: Mehak Nasir <mehaknasir94@gmail.com>
2022-11-21 14:41:40 +05:00
Ghassan Maslamani
bdfa1fdeb3 fix: force studio url to reload if changed
This chagne make it possible if this module was loaded **then**
  the configuration for studio url is changed, then it will pick
  the last value.

  More context overhangio/tutor-mfe/issues/86
2022-11-18 13:49:16 +00:00
Raymond Zhou
79f58cc8d0 feat: update to flcc 1.60 (#392) 2022-11-17 12:25:03 -05:00
Adolfo R. Brandes
437d0a37a9 docs: Document current feature-set of this MFE
This updates the README to include brief documentation on the purpose of
this MFE, the main features it provides, as well as their configuration
and external requirements.
2022-11-17 14:13:23 +00:00
Saad Yousaf
0e24a0767b fix: show Zoom settings when pii sharing is enabled and make launch email optional (#387)
Co-authored-by: Mehak Nasir <mehaknasir94@gmail.com>
2022-11-16 13:14:16 +05:00
connorhaugh
91abf56977 feat: upgrade flcc to 1.58.0 (#390) 2022-11-15 13:31:15 -05:00
Kristin Aoki
f0734d86db feat: upgrade frontend-lib-components to 1.57.0 (#388) 2022-11-15 09:27:46 -05:00
Ahtisham Shahid
3581d633c1 Revert "fix: show Zoom settings when pii sharing is enabled and make launch email optional (#380)" (#386)
This reverts commit b8895bef33.
2022-11-11 21:25:41 +05:00
Saad Yousaf
b8895bef33 fix: show Zoom settings when pii sharing is enabled and make launch email optional (#380)
Co-authored-by: Mehak Nasir <mehaknasir94@gmail.com>
2022-11-11 18:08:26 +05:00
Kristin Aoki
89d0d12559 feat: bump upgrade of flcc to 1.56.1 (#385) 2022-11-10 09:35:09 -05:00
Abderraouf Mehdi Bouhali
34fe291268 fix(rtl): mirror legacy page card arrow 2022-11-07 16:03:36 +00:00
Jenkins
4b1e292e1c chore(i18n): update translations 2022-11-06 16:32:32 -05:00
Kristin Aoki
90eb6fd0c3 feat: upgrade frontend-lib-content-components to 1.55.0 (#379) 2022-11-03 19:05:20 -04:00
Kristin Aoki
50da8a0f0b feat: upgrade flcc to 1.52.0 and paragon to 20.18.0 (#377) 2022-11-02 11:26:46 -04:00
Awais Ansari
7dcd328f2e fix: add . to helper message 2022-11-01 17:39:01 +05:00
Awais Ansari
e2d66cc605 fix: update helper text for bigBlueButton free plan 2022-11-01 17:39:01 +05:00
kenclary
f5fc721b3b feat: update lib-content-components to 1.51.2. TNL-10183. (#371) 2022-10-27 14:46:37 -04:00
Kristin Aoki
c4bbb6fa70 feat: update frontend-lib-content-components to 1.51.1 2022-10-25 11:10:20 -04:00
Muhammad Adeel Tajamul
748aee2cff fix: added bbb learn more env variable (#365)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-10-20 17:31:14 +05:00
Kristin Aoki
31473d3f49 feat: update lib-content-components to 1.49.0 2022-10-19 15:30:18 -04:00
Kristin Aoki
4de727791a feat: update frontend-lib-content-components to 1.48.0 and upgrades paragon to 20.13.0 2022-10-17 15:34:09 -04:00
Zachary Hancock
d1d04d5585 feat: configure lti providers on exam settings modal (#363) 2022-10-13 09:22:36 -04:00
Raymond Zhou
6f41a14012 feat: update FLCC (#362) 2022-10-12 16:02:05 -04:00
97 changed files with 6598 additions and 28363 deletions

1
.env
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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',
},
});
},
);

View File

@@ -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 }}

View 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

View File

@@ -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
View 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

View 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 }}

View File

@@ -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

2
.nvmrc
View File

@@ -1 +1 @@
v16
18

6
Makefile Executable file → Normal file
View 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:

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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": "حفظ",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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');
},
},

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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({

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)}
/>

View File

@@ -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',

View File

@@ -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 = ({

View File

@@ -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);

View File

@@ -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,

View File

@@ -1,3 +1,4 @@
/* eslint-disable react/jsx-no-constructed-context-values */
import React from 'react';
import { initializeMockApp } from '@edx/frontend-platform';

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -9,7 +9,7 @@ const SupportedFeature = ({ name }) => (
</span>
{name}
</>
);
);
SupportedFeature.propTypes = {
name: PropTypes.string.isRequired,

View File

@@ -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

View File

@@ -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,
};
}

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 });

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);

View File

@@ -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');

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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)
}
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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'));

View File

@@ -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>
);
);

View File

@@ -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,

View File

@@ -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,