Compare commits

..

6 Commits

Author SHA1 Message Date
Dmytro
601cf8da66 fix: disable invalid link Video Uploads (#512) 2023-06-08 11:07:09 -04:00
Ghassan Maslamani
1d09a8d099 chore: update translation to olive (#454) 2023-05-18 15:55:01 -04:00
Ihor Romaniuk
80f93fe5b1 feat: replace hardcoded edx string in page head (#424)
* feat: replace hardcoded edx string in page head with site_name from configs

* feat: add ability to obtain site name dynamically and fix tests
2023-03-09 13:51:14 -05:00
Ihor Romaniuk
68b71f2c33 feat: replace hardcoded logo with logo from configs (#423) 2023-01-24 13:23:55 -05:00
Adolfo R. Brandes
8b656b5895 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-15 12:36:15 +00:00
Ghassan Maslamani
a49bff03dc 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-12-12 09:00:06 +00:00
457 changed files with 29756 additions and 36344 deletions

18
.env
View File

@@ -16,8 +16,6 @@ LOGO_URL=''
LOGO_WHITE_URL=''
LOGOUT_URL=null
MARKETING_SITE_BASE_URL=''
TERMS_OF_SERVICE_URL=''
PRIVACY_POLICY_URL=''
ORDER_HISTORY_URL=''
PUBLISHER_BASE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
@@ -26,21 +24,7 @@ SITE_NAME=''
SUPPORT_EMAIL=''
SUPPORT_URL=''
USER_INFO_COOKIE_NAME=''
ENABLE_ACCESSIBILITY_PAGE=false
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_NEW_HOME_PAGE = false
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
ENABLE_NEW_GRADING_PAGE = false
ENABLE_NEW_COURSE_TEAM_PAGE = false
ENABLE_NEW_IMPORT_PAGE = false
ENABLE_NEW_EXPORT_PAGE = false
ENABLE_UNIT_PAGE = false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=false
INVITE_STUDENTS_EMAIL_TO=''
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false

View File

@@ -1,6 +1,6 @@
NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2001'
BASE_URL='localhost:2001'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL=
@@ -16,33 +16,17 @@ LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGOUT_URL='http://localhost:18000/logout'
MARKETING_SITE_BASE_URL='http://localhost:18000'
TERMS_OF_SERVICE_URL=
PRIVACY_POLICY_URL=
ORDER_HISTORY_URL='localhost:1996/orders'
PORT=2001
PUBLISHER_BASE_URL=
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='Your Plaform Name Here'
SITE_NAME='edX'
STUDIO_BASE_URL='http://localhost:18010'
SUPPORT_EMAIL=
SUPPORT_EMAIL='support@example.com'
SUPPORT_URL='https://support.edx.org'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_ACCESSIBILITY_PAGE=false
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_NEW_HOME_PAGE = false
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
ENABLE_NEW_GRADING_PAGE = false
ENABLE_NEW_COURSE_TEAM_PAGE = false
ENABLE_NEW_IMPORT_PAGE = false
ENABLE_NEW_EXPORT_PAGE = false
ENABLE_UNIT_PAGE = false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6
HOTJAR_DEBUG=true
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false

View File

@@ -1,5 +1,5 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2001'
BASE_URL='localhost:2001'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
@@ -28,14 +28,4 @@ USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_NEW_HOME_PAGE = false
ENABLE_NEW_COURSE_OUTLINE_PAGE = true
ENABLE_NEW_VIDEO_UPLOAD_PAGE = true
ENABLE_NEW_GRADING_PAGE = true
ENABLE_NEW_COURSE_TEAM_PAGE = true
ENABLE_NEW_IMPORT_PAGE = true
ENABLE_NEW_EXPORT_PAGE = true
ENABLE_UNIT_PAGE = true
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true

View File

@@ -1,8 +1,6 @@
// 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, {
@@ -10,8 +8,6 @@ module.exports = createConfig(
}],
'template-curly-spacing': 'off',
'react-hooks/exhaustive-deps': 'off',
indent: ['error', 2],
'no-restricted-exports': 'off',
indent: '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

@@ -1,20 +0,0 @@
# 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-v3.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master

View File

@@ -1,12 +0,0 @@
# 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

@@ -1,12 +0,0 @@
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,13 +9,14 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16]
steps:
- uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v3
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VER }}
node-version: ${{ matrix.node }}
- run: make validate.ci
- name: Upload coverage
uses: codecov/codecov-action@v3

2
.nvmrc
View File

@@ -1 +1 @@
18
v16

View File

@@ -1,34 +0,0 @@
{
"extends": ["@edx/stylelint-config-edx"],
"rules": {
"selector-pseudo-class-no-unknown": [true, {
"ignorePseudoClasses": ["export"]
}],
"unit-no-unknown": [true, {
"ignoreUnits": ["\\.5"]
}],
"property-no-vendor-prefix": [true, {
"ignoreProperties": ["animation", "filter"]
}],
"value-no-vendor-prefix": [true, {
"ignoreValues": ["fill-available"]
}],
"function-no-unknown": null,
"number-leading-zero": "never",
"no-descending-specificity": null,
"selector-class-pattern": null,
"scss/no-global-function-names": null,
"color-hex-case": "upper",
"color-hex-length": "long",
"scss/dollar-variable-empty-line-before": null,
"scss/dollar-variable-colon-space-after": "at-least-one-space",
"at-rule-no-unknown": null,
"scss/at-rule-no-unknown": true,
"scss/at-import-partial-extension": null,
"scss/comment-no-empty": null,
"property-no-unknown": [true, {
"ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"]
}],
"alpha-value-notation": "number"
}
}

21
Makefile Normal file → Executable file
View File

@@ -1,11 +1,12 @@
transifex_resource = frontend-app-course-authoring
export TRANSIFEX_RESOURCE = ${transifex_resource}
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA,it_IT,pt_PT,de_DE"
transifex_langs = "ar,fr,es_419,zh_CN"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
@@ -44,23 +45,9 @@ push_translations:
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
else
# Pulls translations using atlas.
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring
$(intl_imports) paragon frontend-component-footer frontend-app-course-authoring
endif
tx pull -f --mode reviewed --languages=$(transifex_langs)
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -1,223 +1,39 @@
|Build Status| |Codecov| |license|
frontend-app-course-authoring
#############################
=============================
|license-badge| |status-badge| |codecov-badge|
Please tag `@edx/teaching-and-learning <https://github.com/orgs/edx/teams/teaching-and-learning>`_ on any PRs or issues. Thanks.
Prerequisite
------------
Purpose
*******
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below.
************
Getting Started
************
Prerequisites
=============
The `devstack`_ is currently recommended as a development environment for your
new MFE. If you start it with ``make dev.up.lms`` that should give you
everything you need as a companion to this frontend.
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Devstack: https://github.com/openedx/devstack
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
Cloning and Startup
===================
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
Installation and Startup
------------------------
1. Clone the repo:
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
2. Use node v18.x.
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm use`_.
3. Install npm dependencies:
2. Install npm dependencies:
``cd frontend-app-course-authoring && npm install``
4. Start the dev server:
3. Start the dev server:
``npm start``
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
or whatever port you setup.
********
Features
********
Feature: Pages and Resources Studio Tab
=======================================
Enables a "Pages & Resources" menu item in Studio, under the "Content" menu.
Requirements
------------
The following are external requirements for this feature to function correctly:
* ``edx-platform`` Django settings:
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
* ``edx-platform`` Waffle flags:
* ``discussions.pages_and_resources_mfe``: must be enabled for the set of users meant to access this feature.
* `frontend-app-learning <https://github.com/openedx/frontend-app-learning>`_: This MFE expects it to be the LMS frontend.
* `frontend-app-discussions <https://github.com/openedx/frontend-app-discussions/>`_: This is what the "Discussions" configuration provided by this feature actually configures. Without it, discussion settings are ignored.
Configuration
-------------
In additional to the standard settings, the following local configuration items are required:
* ``LEARNING_BASE_URL``: points to Learning MFE; necessary so that the `View Live` button works
* ``ENABLE_PROGRESS_GRAPH_SETTINGS``: allow enabling or disabling the learner progress graph course-wide
Feature Description
-------------------
Clicking on the "Pages & Resources" menu item takes the user to the course's ``pages-and-resources`` standalone page in this MFE. (In a devstack, for instance: http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources.)
UX-wise, **Pages & Resources** is meant to look like a Studio tab, so reproduces Studio's header.
For a particular course, this page allows one to:
* Configure the new Discussions MFE (making this a requirement for it). This includes:
* Enabling/disabling the feature entirely
* Picking a different discussion provider, while showing a comparison matrix between them:
* edX
* Ed Discussion
* InScribe
* Piazza
* Yellowdig
* Allowing to configure the selected provider
* Enable/Disable learner progress
* Enable/Disable learner notes
* Enable/Disable the learner wiki
* Enable/Disable the LMS calculator
* Go to the textbook management page in Studio (in a devstack: http://localhost:18010/textbooks/course-v1:edX+DemoX+Demo_Course)
* Go to the custom page management page in Studio(in a devstack http://localhost:18010/tabs/course-v1:edX+DemoX+Demo_Course)
Feature: New React XBlock Editors
=================================
This allows an operator to enable the use of new React editors for the HTML, Video, and Problem XBlocks, all of which are provided here.
Requirements
------------
* ``edx-platform`` Django settings:
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
* ``edx-platform`` Waffle flags:
* ``new_core_editors.use_new_text_editor``: must be enabled for the new HTML Xblock editor to be used in Studio
* ``new_core_editors.use_new_video_editor``: must be enabled for the new Video Xblock editor to be used in Studio
* ``new_core_editors.use_new_problem_editor``: must be enabled for the new Problem Xblock editor to be used in Studio
Configuration
-------------
In additional to the standard settings, the following local configuration item is required:
* ``ENABLE_NEW_EDITOR_PAGES``: must be enabled in order to actually present the new XBlock editors
Feature Description
-------------------
When a corresponding waffle flag is set, upon editing a block in Studio, the view is rendered by this MFE instead of by the XBlock's authoring view. The user remains in Studio.
.. note::
The new editors themselves are currently implemented in a repository outside ``openedx``: `frontend-lib-content-components <https://github.com/edx/frontend-lib-content-components/>`_, a dependency of this MFE. This repository is slated to be moved to the ``openedx`` org, however.
Feature: New Proctoring Exams View
==================================
Requirements
------------
* ``edx-platform`` Django settings:
* ``COURSE_AUTHORING_MICROFRONTEND_URL``: must be set in the CMS environment and point to this MFE's deployment URL.
* ``ZENDESK_*``: necessary if automatic ZenDesk ticket creation is desired
* ``edx-platform`` Feature flags:
* ``ENABLE_EXAM_SETTINGS_HTML_VIEW``: this feature flag must be enabled for the link to the settings view to be shown
* `edx-exams <https://github.com/edx/edx-exams>`_: for this feature to work, the ``edx-exams`` IDA must be deployed and its API accessible by the browser
Configuration
-------------
In additional to the standard settings, the following local configuration item is required:
* ``EXAMS_BASE_URL``: URL to the ``edx-exams`` deployment
Feature Description
-------------------
In Studio, a new item ("Proctored Exam Settings") is added to "Other Course Settings" in the course's "Certificates" settings page. When clicked, this takes the author to the corresponding page in the Course Authoring MFE, where one can:
* Enable proctored exams for the course
* Allow opting out of proctored exams
* Select a proctoring provider
* Enable automatic creation of Zendesk tickets for "suspicious" proctored exam attempts
**********
Developing
**********
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
If your devstack includes the default Demo course, you can visit the following URLs to see content:
- `Proctored Exam Settings <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/proctored-exam-settings>`_
- `Pages and Resources <http://localhost:2001/course/course-v1:edX+DemoX+Demo_Course/pages-and-resources>`_ (work in progress)
Troubleshooting
========================
* ``npm ERR! gyp ERR! build error`` while running npm install on Macs with M1 processors: Probably due to a compatibility issue of node-canvas with M1.
Run ``brew install pkg-config pixman cairo pango libpng jpeg giflib librsvg`` before ``npm install`` to get the correct versions of the dependencies.
If there is still an error, look for "no package [...] found" in the error message and install missing package via brew.
(https://github.com/Automattic/node-canvas/issues/1733)
*********
Deploying
*********
Production Build
================
----------------
The production build is created with ``npm run build``.
@@ -227,87 +43,3 @@ The production build is created with ``npm run build``.
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
:target: @edx/frontend-app-course-authoring
Internationalization
====================
Please see refer to the `frontend-platform i18n howto`_ for documentation on
internationalization.
.. _frontend-platform i18n howto: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
Getting Help
************
If you're having trouble, we have discussion forums at
https://discuss.openedx.org where you can connect with others in the community.
Our real-time conversations are on Slack. You can request a `Slack
invitation`_, then join our `community Slack workspace`_. Because this is a
frontend repository, the best place to discuss it would be in the `#wg-frontend
channel`_.
For anything non-trivial, the best path is to open an issue in this repository
with as many details about the issue you are facing as you can provide.
https://github.com/openedx/frontend-app-course-authoring/issues
For more information about these options, see the `Getting Help`_ page.
.. _Slack invitation: https://openedx.org/slack
.. _community Slack workspace: https://openedx.slack.com/
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
License
*******
The code in this repository is licensed under the AGPLv3 unless otherwise
noted.
Please see `LICENSE <LICENSE>`_ for details.
Contributing
************
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
to have a discussion about your new feature idea with the maintainers prior to
beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.
The Open edX Code of Conduct
****************************
All community members are expected to follow the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
People
******
The assigned maintainers for this component and other project details may be
found in `Backstage`_. Backstage pulls this data from the ``catalog-info.yaml``
file in this repo.
.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/frontend-app-course-authoring
Reporting Security Issues
*************************
Please do not report security issues in public, and email security@openedx.org instead.
.. |license-badge| image:: https://img.shields.io/github/license/openedx/frontend-app-course-authoring.svg
:target: https://github.com/openedx/frontend-app-course-authoring/blob/master/LICENSE
:alt: License
.. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen
.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-course-authoring/coverage.svg?branch=master
:target: https://codecov.io/github/openedx/frontend-app-course-authoring?branch=master
:alt: Codecov

33509
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,9 +12,8 @@
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"stylelint": "stylelint \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests"
@@ -35,11 +34,11 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "12.0.0",
"@edx/frontend-enterprise-hotjar": "^1.2.1",
"@edx/frontend-lib-content-components": "^1.169.3",
"@edx/frontend-platform": "4.2.0",
"@edx/paragon": "^20.45.4",
"@edx/frontend-build": "^11.0.0",
"@edx/frontend-component-footer": "11.1.1",
"@edx/frontend-lib-content-components": "^1.43.0",
"@edx/frontend-platform": "2.5.1",
"@edx/paragon": "20.6.1",
"@fortawesome/fontawesome-svg-core": "1.2.28",
"@fortawesome/free-brands-svg-icons": "5.11.2",
"@fortawesome/free-regular-svg-icons": "5.11.2",
@@ -50,20 +49,16 @@
"core-js": "3.8.1",
"email-validator": "2.0.4",
"formik": "2.2.6",
"jszip": "^3.10.1",
"lodash": "4.17.21",
"moment": "2.29.2",
"prop-types": "15.7.2",
"react": "16.14.0",
"react-datepicker": "^4.13.0",
"react-dom": "16.14.0",
"react-helmet": "^6.1.0",
"react-ranger": "^2.1.0",
"react-redux": "7.1.3",
"react-responsive": "8.1.0",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-textarea-autosize": "^8.4.1",
"react-router": "5.1.2",
"react-router-dom": "5.1.2",
"react-transition-group": "4.4.1",
"redux": "4.0.5",
"regenerator-runtime": "0.13.7",
@@ -72,9 +67,8 @@
},
"devDependencies": {
"@edx/browserslist-config": "1.0.0",
"@edx/frontend-build": "12.8.6",
"@edx/frontend-build": "^11.0.0",
"@edx/reactifex": "^1.0.3",
"@edx/stylelint-config-edx": "^2.3.0",
"@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.1",
"@testing-library/user-event": "^13.2.1",

View File

@@ -1,58 +1,20 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import Footer from '@edx/frontend-component-footer';
import { useDispatch, useSelector } from 'react-redux';
import {
useLocation,
} from 'react-router-dom';
import { Footer } from '@edx/frontend-lib-content-components';
import Header from './studio-header/Header';
import { fetchCourseDetail } from './data/thunks';
import { useModel } from './generic/model-store';
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { getCourseAppsApiStatus, getLoadingStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants';
import Loading from './generic/Loading';
const AppHeader = ({
courseNumber, courseOrg, courseTitle, courseId,
}) => (
<Header
courseNumber={courseNumber}
courseOrg={courseOrg}
courseTitle={courseTitle}
courseId={courseId}
/>
);
AppHeader.propTypes = {
courseId: PropTypes.string.isRequired,
courseNumber: PropTypes.string,
courseOrg: PropTypes.string,
courseTitle: PropTypes.string.isRequired,
};
AppHeader.defaultProps = {
courseNumber: null,
courseOrg: null,
};
const AppFooter = () => (
<div className="mt-6">
<Footer
marketingBaseUrl={process.env.MARKETING_SITE_BASE_URL}
termsOfServiceUrl={process.env.TERMS_OF_SERVICE_URL}
privacyPolicyUrl={process.env.PRIVACY_POLICY_URL}
supportEmail={process.env.SUPPORT_EMAIL}
platformName={process.env.SITE_NAME}
lmsBaseUrl={process.env.LMS_BASE_URL}
studioBaseUrl={process.env.STUDIO_BASE_URL}
showAccessibilityPage={process.env.ENABLE_ACCESSIBILITY_PAGE === 'true'}
/>
</div>
);
const CourseAuthoringPage = ({ courseId, children }) => {
export default function CourseAuthoringPage({ courseId, children }) {
const dispatch = useDispatch();
useEffect(() => {
@@ -65,36 +27,41 @@ const CourseAuthoringPage = ({ courseId, children }) => {
const courseOrg = courseDetail ? courseDetail.org : null;
const courseTitle = courseDetail ? courseDetail.name : courseId;
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
const inProgress = useSelector(state => state.courseDetail.status) === RequestStatus.IN_PROGRESS;
const inProgress = useSelector(getLoadingStatus) === RequestStatus.IN_PROGRESS;
const { pathname } = useLocation();
const showHeader = !pathname.includes('/editor');
if (courseAppsApiStatus === RequestStatus.DENIED) {
return (
<PermissionDeniedAlert />
);
}
const AppHeader = () => (
<Header
courseNumber={courseNumber}
courseOrg={courseOrg}
courseTitle={courseTitle}
courseId={courseId}
/>
);
const AppFooter = () => (
<div className="mt-6">
<Footer />
</div>
);
return (
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
{/* While V2 Editors are temporarily served from their own pages
<div className="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 ? showHeader && <Loading />
: (showHeader && (
<AppHeader
courseNumber={courseNumber}
courseOrg={courseOrg}
courseTitle={courseTitle}
courseId={courseId}
/>
)
)}
{inProgress ? !pathname.includes('/editor/') && <Loading /> : <AppHeader />}
{children}
{!inProgress && showHeader && <AppFooter />}
{!inProgress && <AppFooter />}
</div>
);
};
}
CourseAuthoringPage.propTypes = {
children: PropTypes.node,
@@ -104,5 +71,3 @@ CourseAuthoringPage.propTypes = {
CourseAuthoringPage.defaultProps = {
children: null,
};
export default CourseAuthoringPage;

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { render } from '@testing-library/react';
import { queryByTestId, render } from '@testing-library/react';
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
@@ -23,6 +23,51 @@ jest.mock('react-router-dom', () => ({
}));
let axiosMock;
let store;
let container;
function renderComponent() {
const wrapper = render(
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseAuthoringPage courseId={courseId}>
<PagesAndResources courseId={courseId} />
</CourseAuthoringPage>
</IntlProvider>
</AppProvider>
,
);
container = wrapper.container;
}
const mockStore = async () => {
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
const courseAppsApiUrl = `${apiBaseUrl}/api/course_apps/v1/apps`;
axiosMock.onGet(`${courseAppsApiUrl}/${courseId}`).reply(403, {
response: { status: 403 },
});
await executeThunk(fetchCourseApps(courseId), store.dispatch);
};
describe('DiscussionsSettings', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
test('renders permission error in case of 403', async () => {
await mockStore();
renderComponent();
expect(queryByTestId(container, 'permissionDeniedAlert')).toBeInTheDocument();
});
});
describe('Editor Pages Load no header', () => {
const mockStoreSuccess = async () => {

View File

@@ -2,19 +2,10 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Switch, useRouteMatch } from 'react-router';
import { PageRoute } from '@edx/frontend-platform/react';
import Placeholder from '@edx/frontend-lib-content-components';
import CourseAuthoringPage from './CourseAuthoringPage';
import { PagesAndResources } from './pages-and-resources';
import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings';
import EditorContainer from './editors/EditorContainer';
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
import CustomPages from './custom-pages';
import FilesAndUploads from './files-and-uploads';
import { AdvancedSettings } from './advanced-settings';
import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -32,91 +23,30 @@ import { CourseUpdates } from './course-updates';
* can move the Header/Footer rendering to this component and likely pull the course detail loading
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
*/
const CourseAuthoringRoutes = ({ courseId }) => {
export default function CourseAuthoringRoutes({ courseId }) {
const { path } = useRouteMatch();
return (
<CourseAuthoringPage courseId={courseId}>
<Switch>
<PageRoute path={`${path}/outline`}>
{process.env.ENABLE_NEW_COURSE_OUTLINE_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
<PageRoute path={`${path}/course_info`}>
<CourseUpdates courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/assets`}>
<FilesAndUploads courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/videos`}>
{process.env.ENABLE_NEW_VIDEO_UPLOAD_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
<PageRoute path={`${path}/pages-and-resources`}>
<PagesAndResources courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/proctored-exam-settings`}>
<ProctoredExamSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/custom-pages`}>
<CustomPages courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/container/:blockId`}>
{process.env.ENABLE_UNIT_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
<PageRoute path={`${path}/editor/course-videos/:blockId`}>
<PageRoute path={`${path}/editor/:blockType/:blockId`}>
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
&& (
<VideoSelectorContainer
courseId={courseId}
/>
)}
</PageRoute>
<PageRoute path={`${path}/editor/:blockType/:blockId?`}>
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
&& (
<EditorContainer
courseId={courseId}
/>
)}
</PageRoute>
<PageRoute path={`${path}/settings/details`}>
<ScheduleAndDetails courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/settings/grading`}>
<GradingSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/course_team`}>
<CourseTeam courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/settings/advanced`}>
<AdvancedSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/import`}>
{process.env.ENABLE_NEW_IMPORT_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
<PageRoute path={`${path}/export`}>
{process.env.ENABLE_NEW_EXPORT_PAGE === 'true'
&& (
<Placeholder />
<EditorContainer
courseId={courseId}
/>
)}
</PageRoute>
</Switch>
</CourseAuthoringPage>
);
};
}
CourseAuthoringRoutes.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CourseAuthoringRoutes;

View File

@@ -1,143 +0,0 @@
import React from 'react';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
import initializeStore from './store';
const courseId = 'course-v1:edX+TestX+Test_Course';
const pagesAndResourcesMockText = 'Pages And Resources';
const proctoredExamSeetingsMockText = 'Proctored Exam Settings';
const editorContainerMockText = 'Editor Container';
const videoSelectorContainerMockText = 'Video Selector Container';
const customPagesMockText = 'Custom Pages';
let store;
const mockComponentFn = jest.fn();
// Mock the TinyMceWidget from frontend-lib-content-components
jest.mock('@edx/frontend-lib-content-components', () => ({
TinyMceWidget: () => <div>Widget</div>,
Footer: () => <div>Footer</div>,
prepareEditorRef: jest.fn(() => ({
refReady: true,
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
})),
}));
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useRouteMatch: () => ({
path: `/course/${courseId}`,
}),
}));
jest.mock('./pages-and-resources/PagesAndResources', () => (props) => {
mockComponentFn(props);
return pagesAndResourcesMockText;
});
jest.mock('./proctored-exam-settings/ProctoredExamSettings', () => (props) => {
mockComponentFn(props);
return proctoredExamSeetingsMockText;
});
jest.mock('./editors/EditorContainer', () => (props) => {
mockComponentFn(props);
return editorContainerMockText;
});
jest.mock('./selectors/VideoSelectorContainer', () => (props) => {
mockComponentFn(props);
return videoSelectorContainerMockText;
});
jest.mock('./custom-pages/CustomPages', () => (props) => {
mockComponentFn(props);
return customPagesMockText;
});
describe('<CourseAuthoringRoutes>', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
// TODO: This test needs to be corrected.
// The problem arose after moving new commits (https://github.com/raccoongang/frontend-app-course-authoring/pull/25)
it.skip('renders the PagesAndResources component when the pages and resources route is active', () => {
render(
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/course/${courseId}/pages-and-resources`]}>
<CourseAuthoringRoutes courseId={courseId} />
</MemoryRouter>
</AppProvider>,
);
expect(screen.queryByText(pagesAndResourcesMockText)).toBeInTheDocument();
expect(screen.queryByText(proctoredExamSeetingsMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
// TODO: This test needs to be corrected.
// The problem arose after moving new commits (https://github.com/raccoongang/frontend-app-course-authoring/pull/25)
it.skip('renders the ProctoredExamSettings component when the proctored exam settings route is active', () => {
render(
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/course/${courseId}/proctored-exam-settings`]}>
<CourseAuthoringRoutes courseId={courseId} />
</MemoryRouter>
</AppProvider>,
);
expect(screen.queryByText(proctoredExamSeetingsMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
it('renders the EditorContainer component when the course editor route is active', () => {
render(
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/course/${courseId}/editor/video/block-id`]}>
<CourseAuthoringRoutes courseId={courseId} />
</MemoryRouter>
</AppProvider>,
);
expect(screen.queryByText(editorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
it('renders the VideoSelectorContainer component when the course videos route is active', () => {
render(
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/course/${courseId}/editor/course-videos/block-id`]}>
<CourseAuthoringRoutes courseId={courseId} />
</MemoryRouter>
</AppProvider>,
);
expect(screen.queryByText(videoSelectorContainerMockText)).toBeInTheDocument();
expect(screen.queryByText(pagesAndResourcesMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
});

View File

@@ -1,265 +0,0 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import {
Container, Button, Layout, StatefulButton, TransitionReplace,
} from '@edx/paragon';
import { CheckCircle, Info, Warning } from '@edx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import Placeholder from '@edx/frontend-lib-content-components';
import AlertProctoringError from '../generic/AlertProctoringError';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import { parseArrayOrObjectValues } from '../utils';
import { RequestStatus } from '../data/constants';
import SubHeader from '../generic/sub-header/SubHeader';
import AlertMessage from '../generic/alert-message';
import { fetchCourseAppSettings, updateCourseAppSetting, fetchProctoringExamErrors } from './data/thunks';
import {
getCourseAppSettings, getSavingStatus, getProctoringExamErrors, getSendRequestErrors, getLoadingStatus,
} from './data/selectors';
import SettingCard from './setting-card/SettingCard';
import SettingsSidebar from './settings-sidebar/SettingsSidebar';
import validateAdvancedSettingsData from './utils';
import messages from './messages';
import ModalError from './modal-error/ModalError';
import { useAdvancedSettings } from './hooks';
const AdvancedSettings = ({ intl, courseId }) => {
const dispatch = useDispatch();
const [saveSettingsPrompt, showSaveSettingsPrompt] = useState(false);
const [showDeprecated, setShowDeprecated] = useState(false);
const [errorModal, showErrorModal] = useState(false);
const [editedSettings, setEditedSettings] = useState({});
const [errorFields, setErrorFields] = useState([]);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [isQueryPending, setIsQueryPending] = useState(false);
const [isEditableState, setIsEditableState] = useState(false);
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
const {
advancedSettingsData,
isLoading,
updateSettingsButtonState,
proctoringErrors,
mfeProctoredExamSettingsUrl,
loadingSettingsStatus,
savingStatus,
} = useAdvancedSettings({
dispatch,
courseId,
intl,
setIsQueryPending,
setShowSuccessAlert,
setIsEditableState,
showSaveSettingsPrompt,
showErrorModal,
setErrorFields,
hasInternetConnectionError,
});
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}
if (loadingSettingsStatus === RequestStatus.DENIED) {
return (
<div className="row justify-contnt-center m-6">
<Placeholder />
</div>
);
}
const handleResetSettingsValues = () => {
setIsEditableState(false);
showErrorModal(false);
setEditedSettings({});
showSaveSettingsPrompt(false);
};
const handleSettingBlur = () => {
validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
};
const handleUpdateAdvancedSettingsData = () => {
const isValid = validateAdvancedSettingsData(editedSettings, setErrorFields, setEditedSettings);
if (isValid) {
setIsQueryPending(true);
} else {
showSaveSettingsPrompt(false);
showErrorModal(!errorModal);
}
};
const handleInternetConnectionFailed = () => {
setInternetConnectionError(true);
showSaveSettingsPrompt(false);
setShowSuccessAlert(false);
};
const handleQueryProcessing = () => {
setShowSuccessAlert(false);
dispatch(updateCourseAppSetting(courseId, parseArrayOrObjectValues(editedSettings)));
};
const handleManuallyChangeClick = (setToState) => {
showErrorModal(setToState);
showSaveSettingsPrompt(true);
};
return (
<>
<Container size="xl" className="px-4">
<div className="setting-header mt-5">
{(proctoringErrors?.length > 0) && (
<AlertProctoringError
icon={Info}
proctoringErrorsData={proctoringErrors}
aria-hidden="true"
aria-labelledby={intl.formatMessage(messages.alertProctoringAriaLabelledby)}
aria-describedby={intl.formatMessage(messages.alertProctoringDescribedby)}
/>
)}
<TransitionReplace>
{showSuccessAlert ? (
<AlertMessage
key={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
show={showSuccessAlert}
variant="success"
icon={CheckCircle}
title={intl.formatMessage(messages.alertSuccess)}
description={intl.formatMessage(messages.alertSuccessDescriptions)}
aria-hidden="true"
aria-labelledby={intl.formatMessage(messages.alertSuccessAriaLabelledby)}
aria-describedby={intl.formatMessage(messages.alertSuccessAriaDescribedby)}
/>
) : null}
</TransitionReplace>
</div>
<SubHeader
subtitle={intl.formatMessage(messages.headingSubtitle)}
title={intl.formatMessage(messages.headingTitle)}
contentTitle={intl.formatMessage(messages.policy)}
/>
<section className="setting-items mb-4">
<Layout
lg={[{ span: 9 }, { span: 3 }]}
md={[{ span: 9 }, { span: 3 }]}
sm={[{ span: 9 }, { span: 3 }]}
xs={[{ span: 9 }, { span: 3 }]}
xl={[{ span: 9 }, { span: 3 }]}
>
<Layout.Element>
<article>
<div>
<section className="setting-items-policies">
<div className="small">
<FormattedMessage
id="course-authoring.advanced-settings.policies.description"
defaultMessage="{notice} Do not modify these policies unless you are familiar with their purpose."
values={{ notice: <strong>Warning: </strong> }}
/>
</div>
<div className="setting-items-deprecated-setting">
<Button
variant={showDeprecated ? 'outline-brand' : 'tertiary'}
onClick={() => setShowDeprecated(!showDeprecated)}
size="sm"
>
<FormattedMessage
id="course-authoring.advanced-settings.deprecated.button.text"
defaultMessage="{visibility} deprecated settings"
values={{
visibility:
showDeprecated ? intl.formatMessage(messages.deprecatedButtonHideText)
: intl.formatMessage(messages.deprecatedButtonShowText),
}}
/>
</Button>
</div>
<ul className="setting-items-list p-0">
{Object.keys(advancedSettingsData).map((settingName) => {
const settingData = advancedSettingsData[settingName];
if (settingData.deprecated && !showDeprecated) {
return null;
}
return (
<SettingCard
key={settingName}
settingData={settingData}
name={settingName}
showSaveSettingsPrompt={showSaveSettingsPrompt}
saveSettingsPrompt={saveSettingsPrompt}
setEdited={setEditedSettings}
handleBlur={handleSettingBlur}
isEditableState={isEditableState}
setIsEditableState={setIsEditableState}
/>
);
})}
</ul>
</section>
</div>
</article>
</Layout.Element>
<Layout.Element>
<SettingsSidebar
courseId={courseId}
proctoredExamSettingsUrl={mfeProctoredExamSettingsUrl}
/>
</Layout.Element>
</Layout>
</section>
</Container>
<div className="alert-toast">
{isQueryPending && (
<InternetConnectionAlert
isFailed={savingStatus === RequestStatus.FAILED}
isQueryPending={isQueryPending}
onQueryProcessing={handleQueryProcessing}
onInternetConnectionFailed={handleInternetConnectionFailed}
/>
)}
<AlertMessage
show={saveSettingsPrompt}
aria-hidden={saveSettingsPrompt}
aria-labelledby={intl.formatMessage(messages.alertWarningAriaLabelledby)}
aria-describedby={intl.formatMessage(messages.alertWarningAriaDescribedby)}
role="dialog"
actions={[
!isQueryPending && (
<Button variant="tertiary" onClick={handleResetSettingsValues}>
{intl.formatMessage(messages.buttonCancelText)}
</Button>
),
<StatefulButton
key="statefulBtn"
onClick={handleUpdateAdvancedSettingsData}
state={isQueryPending ? RequestStatus.PENDING : 'default'}
{...updateSettingsButtonState}
/>,
].filter(Boolean)}
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.alertWarning)}
description={intl.formatMessage(messages.alertWarningDescriptions)}
/>
</div>
<ModalError
isError={errorModal}
showErrorModal={(setToState) => handleManuallyChangeClick(setToState)}
handleUndoChanges={handleResetSettingsValues}
settingsData={advancedSettingsData}
errorList={errorFields.length > 0 ? errorFields : []}
/>
</>
);
};
AdvancedSettings.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default injectIntl(AdvancedSettings);

View File

@@ -1,164 +0,0 @@
import React from 'react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import { executeThunk } from '../utils';
import { advancedSettingsMock } from './__mocks__';
import { getCourseAdvancedSettingsApiUrl } from './data/api';
import { updateCourseAppSetting } from './data/thunks';
import AdvancedSettings from './AdvancedSettings';
import messages from './messages';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
// Mock the TextareaAutosize component
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea
{...props}
onFocus={() => {}}
onBlur={() => {}}
/>
)));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<AdvancedSettings intl={injectIntl} courseId={courseId} />
</IntlProvider>
</AppProvider>
);
describe('<AdvancedSettings />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(200, advancedSettingsMock);
});
it('should render without errors', async () => {
const { getByText } = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
const advancedSettingsElement = getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
});
expect(advancedSettingsElement).toBeInTheDocument();
expect(getByText(messages.policy.defaultMessage)).toBeInTheDocument();
expect(getByText(/Do not modify these policies unless you are familiar with their purpose./i)).toBeInTheDocument();
});
});
it('should render setting element', async () => {
const { getByText, queryByText } = render(<RootWrapper />);
await waitFor(() => {
const advancedModuleListTitle = getByText(/Advanced Module List/i);
expect(advancedModuleListTitle).toBeInTheDocument();
expect(queryByText('Certificate web/html view enabled')).toBeNull();
});
});
it('should change to onСhange', async () => {
const { getByLabelText } = render(<RootWrapper />);
await waitFor(() => {
const textarea = getByLabelText(/Advanced Module List/i);
expect(textarea).toBeInTheDocument();
fireEvent.change(textarea, { target: { value: '[1, 2, 3]' } });
expect(textarea.value).toBe('[1, 2, 3]');
});
});
it('should display a warning alert', async () => {
const { getByLabelText, getByText } = render(<RootWrapper />);
await waitFor(() => {
const textarea = getByLabelText(/Advanced Module List/i);
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(getByText(messages.buttonCancelText.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.buttonSaveText.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.alertWarning.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.alertWarningDescriptions.defaultMessage)).toBeInTheDocument();
});
});
it('should display a tooltip on clicking on the icon', async () => {
const { getByLabelText, getByText } = render(<RootWrapper />);
await waitFor(() => {
const button = getByLabelText(/Show help text/i);
fireEvent.click(button);
expect(getByText(/Enter the names of the advanced modules to use in your course./i)).toBeInTheDocument();
});
});
it('should change deprecated button text ', async () => {
const { getByText } = render(<RootWrapper />);
await waitFor(() => {
const showDeprecatedItemsBtn = getByText(/Show Deprecated Settings/i);
expect(showDeprecatedItemsBtn).toBeInTheDocument();
fireEvent.click(showDeprecatedItemsBtn);
expect(getByText(/Hide Deprecated Settings/i)).toBeInTheDocument();
});
expect(getByText('Certificate web/html view enabled')).toBeInTheDocument();
});
it('should reset to default value on click on Cancel button', async () => {
const { getByLabelText, getByText } = render(<RootWrapper />);
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea.value).toBe('[3, 2, 1]');
fireEvent.click(getByText(messages.buttonCancelText.defaultMessage));
expect(textarea.value).toBe('[]');
});
it('should update the textarea value and display the updated value after clicking "Change manually"', async () => {
const { getByLabelText, getByText } = render(<RootWrapper />);
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1,' } });
expect(textarea.value).toBe('[3, 2, 1,');
fireEvent.click(getByText('Save changes'));
fireEvent.click(getByText('Change manually'));
expect(textarea.value).toBe('[3, 2, 1,');
});
it('should show success alert after save', async () => {
const { getByLabelText, getByText } = render(<RootWrapper />);
let textarea;
await waitFor(() => {
textarea = getByLabelText(/Advanced Module List/i);
});
fireEvent.change(textarea, { target: { value: '[3, 2, 1]' } });
expect(textarea.value).toBe('[3, 2, 1]');
axiosMock
.onPatch(`${getCourseAdvancedSettingsApiUrl(courseId)}`)
.reply(200, {
...advancedSettingsMock,
advancedModules: {
...advancedSettingsMock.advancedModules,
value: [3, 2, 1],
},
});
fireEvent.click(getByText('Save changes'));
await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch);
expect(getByText('Your policy changes have been saved.')).toBeInTheDocument();
});
});

View File

@@ -1,16 +0,0 @@
module.exports = {
advancedModules: {
deprecated: false,
displayName: 'Advanced Module List',
help: 'Enter the names of the advanced modules to use in your course.',
hideOnEnabledPublisher: false,
value: [],
},
certHtmlViewEnabled: {
deprecated: true,
display_name: 'Certificate web/html view enabled',
help: 'If true, certificate Web/HTML views are enabled for the course.',
hide_on_enabled_publisher: false,
value: true,
},
};

View File

@@ -1,2 +0,0 @@
// eslint-disable-next-line import/prefer-default-export
export { default as advancedSettingsMock } from './advancedSettings';

View File

@@ -1,41 +0,0 @@
/* eslint-disable import/prefer-default-export */
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { convertObjectToSnakeCase } from '../../utils';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseAdvancedSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings/${courseId}`;
const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/proctoring_errors/`;
/**
* Get's advanced setting for a course.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getCourseAdvancedSettings(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
return camelCaseObject(data);
}
/**
* Updates advanced setting for a course.
* @param {string} courseId
* @param {object} settings
* @returns {Promise<Object>}
*/
export async function updateCourseAdvancedSettings(courseId, settings) {
const { data } = await getAuthenticatedHttpClient()
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
return camelCaseObject(data);
}
/**
* Gets proctoring exam errors.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getProctoringExamErrors(courseId) {
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
return camelCaseObject(data);
}

View File

@@ -1,5 +0,0 @@
export const getLoadingStatus = (state) => state.advancedSettings.loadingStatus;
export const getCourseAppSettings = state => state.advancedSettings.courseAppSettings;
export const getSavingStatus = (state) => state.advancedSettings.savingStatus;
export const getProctoringExamErrors = (state) => state.advancedSettings.proctoringErrors;
export const getSendRequestErrors = (state) => state.advancedSettings.sendRequestErrors.developer_message;

View File

@@ -1,48 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../data/constants';
const slice = createSlice({
name: 'advancedSettings',
initialState: {
loadingStatus: RequestStatus.IN_PROGRESS,
savingStatus: '',
courseAppSettings: {},
proctoringErrors: {},
sendRequestErrors: {},
},
reducers: {
updateLoadingStatus: (state, { payload }) => {
state.loadingStatus = payload.status;
},
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
fetchCourseAppsSettingsSuccess: (state, { payload }) => {
Object.assign(state.courseAppSettings, payload);
},
updateCourseAppsSettingsSuccess: (state, { payload }) => {
Object.assign(state.courseAppSettings, payload);
},
getDataSendErrors: (state, { payload }) => {
Object.assign(state.sendRequestErrors, payload);
},
fetchProctoringExamErrorsSuccess: (state, { payload }) => {
Object.assign(state.proctoringErrors, payload);
},
},
});
export const {
updateLoadingStatus,
updateSavingStatus,
getDataSendErrors,
fetchCourseAppsSettingsSuccess,
updateCourseAppsSettingsSuccess,
fetchProctoringExamErrorsSuccess,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -1,85 +0,0 @@
import { RequestStatus } from '../../data/constants';
import {
getCourseAdvancedSettings,
updateCourseAdvancedSettings,
getProctoringExamErrors,
} from './api';
import {
fetchCourseAppsSettingsSuccess,
updateCourseAppsSettingsSuccess,
updateLoadingStatus,
updateSavingStatus,
fetchProctoringExamErrorsSuccess,
getDataSendErrors,
} from './slice';
export function fetchCourseAppSettings(courseId) {
return async (dispatch) => {
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const settingValues = await getCourseAdvancedSettings(courseId);
const sortedDisplayName = [];
Object.values(settingValues).forEach(value => {
const { displayName } = value;
sortedDisplayName.push(displayName);
});
const sortedSettingValues = {};
sortedDisplayName.sort().forEach((displayName => {
Object.entries(settingValues).forEach(([key, value]) => {
if (value.displayName === displayName) {
sortedSettingValues[key] = value;
}
});
}));
dispatch(fetchCourseAppsSettingsSuccess(sortedSettingValues));
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
if (error.response && error.response.status === 403) {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.DENIED }));
} else {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
}
}
};
}
export function updateCourseAppSetting(courseId, settings) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const settingValues = await updateCourseAdvancedSettings(courseId, settings);
dispatch(updateCourseAppsSettingsSuccess(settingValues));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
let errorData;
try {
const { customAttributes: { httpErrorResponseData } } = error;
errorData = JSON.parse(httpErrorResponseData);
} catch (err) {
errorData = {};
}
dispatch(getDataSendErrors(errorData));
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function fetchProctoringExamErrors(courseId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const settingValues = await getProctoringExamErrors(courseId);
dispatch(fetchProctoringExamErrorsSuccess(settingValues));
return true;
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}

View File

@@ -1,62 +0,0 @@
import { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { RequestStatus } from '../data/constants';
import { fetchCourseAppSettings, fetchProctoringExamErrors } from './data/thunks';
import {
getCourseAppSettings, getSavingStatus, getProctoringExamErrors, getSendRequestErrors, getLoadingStatus,
} from './data/selectors';
import messages from './messages';
/* eslint-disable import/prefer-default-export */
export const useAdvancedSettings = ({
dispatch, courseId, intl, setIsQueryPending, setShowSuccessAlert, setIsEditableState, showSaveSettingsPrompt,
showErrorModal, setErrorFields, hasInternetConnectionError,
}) => {
useEffect(() => {
dispatch(fetchCourseAppSettings(courseId));
dispatch(fetchProctoringExamErrors(courseId));
}, [courseId]);
const advancedSettingsData = useSelector(getCourseAppSettings);
const savingStatus = useSelector(getSavingStatus);
const proctoringExamErrors = useSelector(getProctoringExamErrors);
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
const loadingSettingsStatus = useSelector(getLoadingStatus);
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
const updateSettingsButtonState = {
labels: {
default: intl.formatMessage(messages.buttonSaveText),
pending: intl.formatMessage(messages.buttonSavingText),
},
disabledStates: ['pending'],
};
const {
proctoringErrors,
mfeProctoredExamSettingsUrl,
} = proctoringExamErrors;
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
setIsQueryPending(false);
setShowSuccessAlert(true);
setIsEditableState(false);
setTimeout(() => setShowSuccessAlert(false), 15000);
window.scrollTo({ top: 0, behavior: 'smooth' });
showSaveSettingsPrompt(false);
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
setErrorFields(settingsWithSendErrors);
showErrorModal(true);
}
}, [savingStatus]);
return {
advancedSettingsData,
isLoading,
updateSettingsButtonState,
proctoringErrors,
mfeProctoredExamSettingsUrl,
loadingSettingsStatus,
savingStatus,
};
};

View File

@@ -1,2 +0,0 @@
/* eslint-disable import/prefer-default-export */
export { default as AdvancedSettings } from './AdvancedSettings';

View File

@@ -1,86 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
headingTitle: {
id: 'course-authoring.advanced-settings.heading.title',
defaultMessage: 'Advanced settings',
},
headingSubtitle: {
id: 'course-authoring.advanced-settings.heading.subtitle',
defaultMessage: 'Settings',
},
policy: {
id: 'course-authoring.advanced-settings.policies.title',
defaultMessage: 'Manual policy definition',
},
alertWarning: {
id: 'course-authoring.advanced-settings.alert.warning',
defaultMessage: "You've made some changes",
},
alertWarningDescriptions: {
id: 'course-authoring.advanced-settings.alert.warning.descriptions',
defaultMessage: 'Your changes will not take effect until you save your progress. Take care with key and value formatting, as validation is not implemented.',
},
alertSuccess: {
id: 'course-authoring.advanced-settings.alert.success',
defaultMessage: 'Your policy changes have been saved.',
},
alertSuccessDescriptions: {
id: 'course-authoring.advanced-settings.alert.success.descriptions',
defaultMessage: 'No validation is performed on policy keys or value pairs. If you are having difficulties, check your formatting.',
},
alertProctoringError: {
id: 'course-authoring.advanced-settings.alert.proctoring.error',
defaultMessage: 'This course has protected exam setting that are incomplete or invalid.',
},
alertProctoringErrorDescriptions: {
id: 'course-authoring.advanced-settings.alert.proctoring.error.descriptions',
defaultMessage: 'You will be unable to make changes until the following setting are updated on the page below.',
},
buttonSaveText: {
id: 'course-authoring.advanced-settings.alert.button.save',
defaultMessage: 'Save changes',
},
buttonSavingText: {
id: 'course-authoring.advanced-settings.alert.button.saving',
defaultMessage: 'Saving',
},
buttonCancelText: {
id: 'course-authoring.advanced-settings.alert.button.cancel',
defaultMessage: 'Cancel',
},
deprecatedButtonShowText: {
id: 'course-authoring.advanced-settings.deprecated.button.show',
defaultMessage: 'Show',
},
deprecatedButtonHideText: {
id: 'course-authoring.advanced-settings.deprecated.button.hide',
defaultMessage: 'Hide',
},
alertWarningAriaLabelledby: {
id: 'course-authoring.advanced-settings.alert.warning.aria.labelledby',
defaultMessage: 'notification-warning-title',
},
alertWarningAriaDescribedby: {
id: 'course-authoring.advanced-settings.alert.warning.aria.describedby',
defaultMessage: 'notification-warning-description',
},
alertSuccessAriaLabelledby: {
id: 'course-authoring.advanced-settings.alert.success.aria.labelledby',
defaultMessage: 'alert-confirmation-title',
},
alertSuccessAriaDescribedby: {
id: 'course-authoring.advanced-settings.alert.success.aria.describedby',
defaultMessage: 'alert-confirmation-description',
},
alertProctoringAriaLabelledby: {
id: 'course-authoring.advanced-settings.alert.proctoring.error.aria.labelledby',
defaultMessage: 'alert-danger-title',
},
alertProctoringDescribedby: {
id: 'course-authoring.advanced-settings.alert.proctoring.error.aria.describedby',
defaultMessage: 'alert-danger-description',
},
});
export default messages;

View File

@@ -1,63 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ActionRow, AlertModal, Button } from '@edx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import ModalErrorListItem from './ModalErrorListItem';
import messages from './messages';
const ModalError = ({
intl, isError, handleUndoChanges, showErrorModal, errorList, settingsData,
}) => (
<AlertModal
title={intl.formatMessage(messages.modalErrorTitle)}
isOpen={isError}
variant="danger"
footerNode={(
<ActionRow>
<Button
variant="tertiary"
onClick={() => showErrorModal(!isError)}
>
{intl.formatMessage(messages.modalErrorButtonChangeManually)}
</Button>
<Button onClick={handleUndoChanges}>
{intl.formatMessage(messages.modalErrorButtonUndoChanges)}
</Button>
</ActionRow>
)}
>
<p>
<FormattedMessage
id="course-authoring.advanced-settings.modal.error.description"
defaultMessage="There was {errorCounter} while trying to save the course settings in the database.
Please check the following validation feedbacks and reflect them in your course settings:"
values={{ errorCounter: <strong>{errorList.length} validation error </strong> }}
/>
</p>
<hr />
<ul className="p-0">
{errorList.map((settingName) => (
<ModalErrorListItem
key={settingName.key}
settingName={settingName}
settingsData={settingsData}
/>
))}
</ul>
</AlertModal>
);
ModalError.propTypes = {
intl: intlShape.isRequired,
isError: PropTypes.bool.isRequired,
handleUndoChanges: PropTypes.func.isRequired,
showErrorModal: PropTypes.func.isRequired,
errorList: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string,
message: PropTypes.string,
})).isRequired,
settingsData: PropTypes.shape({}).isRequired,
};
export default injectIntl(ModalError);

View File

@@ -1,58 +0,0 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import ModalError from './ModalError';
import messages from './messages';
const handleUndoChangesMock = jest.fn();
const showErrorModalMock = jest.fn();
const errorList = [
{ key: 'setting1', message: 'Error 1' },
{ key: 'setting2', message: 'Error 2' },
];
const settingsData = {
setting1: 'value1',
setting2: 'value2',
};
const RootWrapper = () => (
<IntlProvider locale="en">
<ModalError
isError
handleUndoChanges={handleUndoChangesMock}
showErrorModal={showErrorModalMock}
errorList={errorList}
settingsData={settingsData}
/>
</IntlProvider>
);
describe('<ModalError />', () => {
it('calls handleUndoChanges when "Undo changes" button is clicked', () => {
const { getByText } = render(<RootWrapper />);
const undoChangesButton = getByText(messages.modalErrorButtonUndoChanges.defaultMessage);
fireEvent.click(undoChangesButton);
expect(handleUndoChangesMock).toHaveBeenCalledTimes(1);
});
it('calls showErrorModal when "Change manually" button is clicked', () => {
const { getByText } = render(<RootWrapper />);
const changeManuallyButton = getByText(messages.modalErrorButtonChangeManually.defaultMessage);
fireEvent.click(changeManuallyButton);
expect(showErrorModalMock).toHaveBeenCalledTimes(1);
});
it('renders error message with correct values', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText(/There was/i)).toBeInTheDocument();
expect(getByText(/2 validation error/i)).toBeInTheDocument();
expect(getByText(/while trying to save the course settings in the database. Please check the following validation feedbacks and reflect them in your course settings:/i)).toBeInTheDocument();
expect(getByText(messages.modalErrorTitle.defaultMessage)).toBeInTheDocument();
});
it('renders correct number of errors', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText('Error 1')).toBeInTheDocument();
expect(getByText('Error 2')).toBeInTheDocument();
});
});

View File

@@ -1,31 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, Icon } from '@edx/paragon';
import { Error } from '@edx/paragon/icons';
import { capitalize } from 'lodash';
import { transformKeysToCamelCase } from '../../utils';
const ModalErrorListItem = ({ settingName, settingsData }) => {
const { displayName } = settingsData[transformKeysToCamelCase(settingName)];
return (
<li className="modal-error-item">
<Alert variant="danger">
<h4 className="modal-error-item-title">
<Icon src={Error} />{capitalize(displayName)}:
</h4>
<p className="m-0">{settingName.message}</p>
</Alert>
</li>
);
};
ModalErrorListItem.propTypes = {
settingName: PropTypes.shape({
key: PropTypes.string,
message: PropTypes.string,
}).isRequired,
settingsData: PropTypes.shape({}).isRequired,
};
export default ModalErrorListItem;

View File

@@ -1,34 +0,0 @@
import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import ModalErrorListItem from './ModalErrorListItem';
const settingName = {
key: 'exampleKey',
message: 'Error message',
};
const settingsData = {
exampleKey: {
displayName: 'Error field',
},
};
const RootWrapper = () => (
<IntlProvider locale="en">
<ModalErrorListItem settingName={settingName} settingsData={settingsData} />
</IntlProvider>
);
describe('<ModalErrorListItem />', () => {
it('renders the display name and error message', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText('Error field:')).toBeInTheDocument();
expect(getByText('Error message')).toBeInTheDocument();
});
it('renders the alert with variant "danger"', () => {
const { getByRole } = render(<RootWrapper />);
expect(getByRole('alert')).toHaveClass('alert-danger');
});
});

View File

@@ -1,18 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
modalErrorTitle: {
id: 'course-authoring.advanced-settings.modal.error.title',
defaultMessage: 'Validation error while saving',
},
modalErrorButtonChangeManually: {
id: 'course-authoring.advanced-settings.modal.error.btn.change-manually',
defaultMessage: 'Change manually',
},
modalErrorButtonUndoChanges: {
id: 'course-authoring.advanced-settings.modal.error.btn.undo-changes',
defaultMessage: 'Undo changes',
},
});
export default messages;

View File

@@ -1,125 +0,0 @@
@import "variables";
.setting-items-policies {
.setting-items-deprecated-setting {
float: right;
margin-bottom: 1.75rem;
}
.instructions,
strong {
color: $text-color-base;
font-weight: 400;
}
}
.setting-card {
margin-bottom: 1.75rem;
.pgn__card-header .pgn__card-header-title-md {
font-size: 1.125rem;
}
}
.alert-toast {
position: fixed;
bottom: 0;
width: 100%;
padding: 0 .625rem;
z-index: $zindex-modal;
}
.alert-proctoring-error {
list-style: none;
}
.setting-items-list {
li {
list-style: none;
}
.form-control {
min-height: 2.75rem;
width: $setting-form-control-width;
}
.pgn__card-section {
max-width: $setting-form-control-width;
}
.pgn__card-header {
padding: 0 0 0 1.5rem;
flex-grow: 1;
}
.pgn__card-status {
padding: .625rem;
}
.pgn__card-header-content {
margin-top: 1.438rem;
margin-bottom: 1.438rem;
display: flex;
flex-direction: revert;
}
}
.setting-sidebar-supplementary {
.setting-sidebar-supplementary-about {
.setting-sidebar-supplementary-about-title {
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
color: $headings-color;
margin-bottom: 1.25rem;
}
.setting-sidebar-supplementary-about-descriptions {
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
color: $text-color-base;
}
}
.setting-sidebar-supplementary-other-links ul {
list-style: none;
.setting-sidebar-supplementary-other-link {
font: normal $font-weight-normal .875rem/1.5rem $font-family-base;
line-height: 1.5rem;
color: $info-500;
margin-bottom: .5rem;
}
}
.setting-sidebar-supplementary-other-title {
font: normal $font-weight-bold 1.125rem/1.5rem $font-family-base;
color: $headings-color;
margin-bottom: 1.25rem;
}
}
.modal-error-item {
list-style: none;
.pgn__icon {
display: inline-block;
margin-right: 5px;
margin-bottom: 5px;
color: $danger;
}
.modal-error-item-title {
display: flex;
align-items: center;
}
}
.modal-popup-content {
max-width: 200px;
color: $white;
background-color: $black;
filter: drop-shadow(0 2px 4px rgba(0 0 0 / .15));
font-weight: 400;
}
.pgn__modal-popup__arrow::after {
border-top-color: $black;
}

View File

@@ -1,2 +0,0 @@
$text-color-base: $gray-700;
$setting-form-control-width: 34.375rem;

View File

@@ -1,138 +0,0 @@
import React, { useState } from 'react';
import {
ActionRow,
Card,
Form,
Icon,
IconButton,
ModalPopup,
useToggle,
} from '@edx/paragon';
import { InfoOutline, Warning } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import { capitalize } from 'lodash';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import TextareaAutosize from 'react-textarea-autosize';
import messages from './messages';
const SettingCard = ({
name,
settingData,
handleBlur,
setEdited,
showSaveSettingsPrompt,
saveSettingsPrompt,
isEditableState,
setIsEditableState,
// injected
intl,
}) => {
const { deprecated, help, displayName } = settingData;
const initialValue = JSON.stringify(settingData.value, null, 4);
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const [newValue, setNewValue] = useState(initialValue);
const handleSettingChange = (e) => {
const { value } = e.target;
setNewValue(e.target.value);
if (value !== initialValue) {
if (!saveSettingsPrompt) {
showSaveSettingsPrompt(true);
}
if (!isEditableState) {
setIsEditableState(true);
}
}
};
const handleCardBlur = () => {
setEdited((prevEditedSettings) => ({
...prevEditedSettings,
[name]: newValue,
}));
handleBlur();
};
return (
<li className="field-group course-advanced-policy-list-item">
<Card className="flex-column setting-card">
<Card.Body className="d-flex">
<Card.Header
title={(
<ActionRow>
{capitalize(displayName)}
<IconButton
ref={setTarget}
onClick={open}
src={InfoOutline}
iconAs={Icon}
alt={intl.formatMessage(messages.helpButtonText)}
variant="primary"
className=" ml-1 mr-2"
/>
<ModalPopup
hasArrow
placement="right"
positionRef={target}
isOpen={isOpen}
onClose={close}
className="pgn__modal-popup__arrow"
>
<div
className="p-2 x-small rounded modal-popup-content"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: help }}
/>
</ModalPopup>
</ActionRow>
)}
/>
<Card.Section>
<Form.Group className="m-0">
<Form.Control
as={TextareaAutosize}
value={isEditableState ? newValue : initialValue}
name={name}
onChange={handleSettingChange}
aria-label={displayName}
onBlur={handleCardBlur}
/>
</Form.Group>
</Card.Section>
</Card.Body>
{deprecated && (
<Card.Status icon={Warning} variant="danger">
{intl.formatMessage(messages.deprecated)}
</Card.Status>
)}
</Card>
</li>
);
};
SettingCard.propTypes = {
intl: intlShape.isRequired,
settingData: PropTypes.shape({
deprecated: PropTypes.bool,
help: PropTypes.string,
displayName: PropTypes.string,
value: PropTypes.PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
PropTypes.number,
PropTypes.object,
PropTypes.array,
]),
}).isRequired,
setEdited: PropTypes.func.isRequired,
showSaveSettingsPrompt: PropTypes.func.isRequired,
name: PropTypes.string.isRequired,
handleBlur: PropTypes.func.isRequired,
saveSettingsPrompt: PropTypes.bool.isRequired,
isEditableState: PropTypes.bool.isRequired,
setIsEditableState: PropTypes.func.isRequired,
};
export default injectIntl(SettingCard);

View File

@@ -1,96 +0,0 @@
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import SettingCard from './SettingCard';
import messages from './messages';
const setEdited = jest.fn();
const showSaveSettingsPrompt = jest.fn();
const setIsEditableState = jest.fn();
const handleBlur = jest.fn();
const settingData = {
deprecated: false,
help: 'This is a help message',
displayName: 'Setting Name',
value: 'Setting Value',
};
jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea
{...props}
onFocus={() => {}}
onBlur={() => {}}
/>
)));
const RootWrapper = () => (
<IntlProvider locale="en">
<SettingCard
intl={{}}
isOn
name="settingName"
setEdited={setEdited}
setIsEditableState={setIsEditableState}
showSaveSettingsPrompt={showSaveSettingsPrompt}
settingData={settingData}
handleBlur={handleBlur}
isEditableState
saveSettingsPrompt={false}
/>
</IntlProvider>
);
describe('<SettingCard />', () => {
afterEach(() => jest.clearAllMocks());
it('renders the setting card with the provided data', () => {
const { getByText, getByLabelText } = render(<RootWrapper />);
const cardTitle = getByText(/Setting Name/i);
const input = getByLabelText(/Setting Name/i);
expect(cardTitle).toBeInTheDocument();
expect(input).toBeInTheDocument();
expect(input.value).toBe(JSON.stringify(settingData.value, null, 4));
});
it('displays the deprecated status when the setting is deprecated', () => {
const deprecatedSettingData = { ...settingData, deprecated: true };
const { getByText } = render(
<IntlProvider locale="en">
<SettingCard
intl={{}}
isOn
name="settingName"
setEdited={setEdited}
setIsEditableState={setIsEditableState}
showSaveSettingsPrompt={showSaveSettingsPrompt}
settingData={deprecatedSettingData}
handleBlur={handleBlur}
isEditable={false}
saveSettingsPrompt
/>
</IntlProvider>,
);
const deprecatedStatus = getByText(messages.deprecated.defaultMessage);
expect(deprecatedStatus).toBeInTheDocument();
});
it('does not display the deprecated status when the setting is not deprecated', () => {
const { queryByText } = render(<RootWrapper />);
expect(queryByText(messages.deprecated.defaultMessage)).toBeNull();
});
it('calls setEdited on blur', async () => {
const { getByLabelText } = render(<RootWrapper />);
const inputBox = getByLabelText(/Setting Name/i);
fireEvent.focus(inputBox);
userEvent.clear(inputBox);
userEvent.type(inputBox, '3, 2, 1');
await waitFor(() => {
expect(inputBox).toHaveValue('3, 2, 1');
});
await (async () => {
expect(setEdited).toHaveBeenCalled();
expect(handleBlur).toHaveBeenCalled();
});
fireEvent.focusOut(inputBox);
});
});

View File

@@ -1,14 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
deprecated: {
id: 'course-authoring.advanced-settings.button.deprecated',
defaultMessage: 'Deprecated',
},
helpButtonText: {
id: 'course-authoring.advanced-settings.button.help',
defaultMessage: 'Show help text',
},
});
export default messages;

View File

@@ -1,47 +0,0 @@
import React from 'react';
import {
FormattedMessage,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import HelpSidebar from '../../generic/help-sidebar';
import messages from './messages';
const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => (
<HelpSidebar
courseId={courseId}
proctoredExamSettingsUrl={proctoredExamSettingsUrl}
showOtherSettings
>
<h4 className="help-sidebar-about-title">
{intl.formatMessage(messages.about)}
</h4>
<p className="help-sidebar-about-descriptions">
{intl.formatMessage(messages.aboutDescription1)}
</p>
<p className="help-sidebar-about-descriptions">
{intl.formatMessage(messages.aboutDescription2)}
</p>
<p className="help-sidebar-about-descriptions">
<FormattedMessage
id="course-authoring.advanced-settings.about.description-3"
defaultMessage="{notice} When you enter strings as policy values, ensure that you use double quotation marks (“) around the string. Do not use single quotation marks ()."
values={{ notice: <strong>Note:</strong> }}
/>
</p>
</HelpSidebar>
);
SettingsSidebar.defaultProps = {
proctoredExamSettingsUrl: '',
};
SettingsSidebar.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
proctoredExamSettingsUrl: PropTypes.string,
};
export default injectIntl(SettingsSidebar);

View File

@@ -1,46 +0,0 @@
import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../store';
import SettingsSidebar from './SettingsSidebar';
import messages from './messages';
const courseId = 'course-123';
let store;
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<SettingsSidebar intl={{ formatMessage: jest.fn() }} courseId={courseId} />
</IntlProvider>
</AppProvider>
);
describe('<SettingsSidebar />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('renders about and other sidebar titles correctly', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText(messages.about.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.other.defaultMessage)).toBeInTheDocument();
});
it('renders about descriptions correctly', () => {
const { getByText } = render(<RootWrapper />);
const aboutThirtyDescription = getByText('When you enter strings as policy values, ensure that you use double quotation marks (“) around the string. Do not use single quotation marks ().');
expect(getByText(messages.aboutDescription1.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.aboutDescription2.defaultMessage)).toBeInTheDocument();
expect(aboutThirtyDescription).toBeInTheDocument();
});
});

View File

@@ -1,47 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
about: {
id: 'course-authoring.advanced-settings.sidebar.about.title',
defaultMessage: 'What do advanced settings do?',
},
aboutDescription1: {
id: 'course-authoring.advanced-settings.sidebar.about.description-1',
defaultMessage: 'Advanced settings control specific course functionality. On this page, you can edit manual policies, which are JSON-based key and value pairs that control specific course settings.',
},
aboutDescription2: {
id: 'course-authoring.advanced-settings.sidebar.about.description-2',
defaultMessage: 'Any policies you modify here override all other information youve defined elsewhere in Studio. Do not edit policies unless you are familiar with both their purpose and syntax.',
},
other: {
id: 'course-authoring.advanced-settings.sidebar.other.title',
defaultMessage: 'Other course settings',
},
otherCourseSettingsLinkToScheduleAndDetails: {
id: 'course-authoring.advanced-settings.sidebar.links.schedule-and-details',
defaultMessage: 'Details & schedule',
description: 'Link to Studio Details & schedule page',
},
otherCourseSettingsLinkToGrading: {
id: 'course-authoring.advanced-settings.sidebar.links.grading',
defaultMessage: 'Grading',
description: 'Link to Studio Grading page',
},
otherCourseSettingsLinkToCourseTeam: {
id: 'course-authoring.advanced-settings.sidebar.links.course-team',
defaultMessage: 'Course team',
description: 'Link to Studio Course team page',
},
otherCourseSettingsLinkToGroupConfigurations: {
id: 'course-authoring.advanced-settings.sidebar.links.group-configurations',
defaultMessage: 'Group configurations',
description: 'Link to Studio Group configurations page',
},
otherCourseSettingsLinkToProctoredExamSettings: {
id: 'course-authoring.advanced-settings.sidebar.links.proctored-exam-settings',
defaultMessage: 'Proctored exam settings',
description: 'Link to Proctored exam settings page',
},
});
export default messages;

View File

@@ -1,48 +0,0 @@
/**
* Validates advanced settings data by checking if the provided settings are correctly formatted JSON.
* It performs validation on a given object of settings, detects incorrectly formatted settings,
* and sets error fields accordingly using the setErrorFields function.
*
* @param {object} settingObj - The object containing the settings to validate.
* @param {function} setErrorFields - The function to set error fields.
* @returns {boolean} - `true` if the data is valid, otherwise `false`.
*/
export default function validateAdvancedSettingsData(settingObj, setErrorFields, setEditedSettings) {
const fieldsWithErrors = [];
const pushDataToErrorArray = (settingName) => {
fieldsWithErrors.push({ key: settingName, message: 'Incorrectly formatted JSON' });
};
Object.entries(settingObj).forEach(([settingName, settingValue]) => {
try {
JSON.parse(settingValue);
} catch (e) {
let targetSettingValue = settingValue;
const firstNonWhite = settingValue.substring(0, 1);
const isValid = !['{', '[', "'"].includes(firstNonWhite);
if (isValid) {
try {
targetSettingValue = `"${ targetSettingValue.trim() }"`;
JSON.parse(targetSettingValue);
setEditedSettings((prevEditedSettings) => ({
...prevEditedSettings,
[settingName]: targetSettingValue,
}));
} catch (quotedE) { /* empty */ }
}
pushDataToErrorArray(settingName);
}
});
setErrorFields((prevState) => {
if (JSON.stringify(prevState) !== JSON.stringify(fieldsWithErrors)) {
return fieldsWithErrors;
}
return prevState;
});
return fieldsWithErrors.length === 0;
}

View File

@@ -1,29 +0,0 @@
import validateAdvancedSettingsData from './utils';
describe('validateAdvancedSettingsData', () => {
it('should validate correctly formatted settings and return true', () => {
const settingObj = {
setting1: '{ "key": "value" }',
setting2: '{ "key": "value" }',
};
const setErrorFieldsMock = jest.fn();
const setEditedSettingsMock = jest.fn();
const isValid = validateAdvancedSettingsData(settingObj, setErrorFieldsMock, setEditedSettingsMock);
expect(isValid).toBe(true);
expect(setErrorFieldsMock).toHaveBeenCalledTimes(1);
expect(setEditedSettingsMock).toHaveBeenCalledTimes(0);
});
it('should validate incorrectly formatted settings and set error fields', () => {
const settingObj = {
setting1: '{ "key": "value" }',
setting2: 'incorrectJSON',
setting3: '{ "key": "value" }',
};
const setErrorFieldsMock = jest.fn();
const setEditedSettingsMock = jest.fn();
const isValid = validateAdvancedSettingsData(settingObj, setErrorFieldsMock, setEditedSettingsMock);
expect(isValid).toBe(false);
expect(setErrorFieldsMock).toHaveBeenCalledTimes(1);
expect(setEditedSettingsMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,9 +0,0 @@
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -1,81 +0,0 @@
.form-group-custom {
.pgn__form-label {
font: normal $font-weight-bold .75rem/1.25rem $font-family-base;
color: $gray-500;
margin-bottom: .5rem;
}
.pgn__form-control-description,
.pgn__form-text {
font: normal $font-weight-normal .75rem/1.25rem $font-family-base;
color: $gray-500;
margin-top: .5rem;
}
.dropdown-toggle {
width: 100%;
justify-content: space-between;
}
.form-group-custom_isInvalid {
input {
border-color: $form-feedback-invalid-color;
}
}
.feedback-error {
color: $form-feedback-invalid-color;
}
}
.datepicker-custom {
margin: 0;
.datepicker-custom-control {
display: block;
width: 100%;
font-size: $input-font-size;
font-weight: $input-font-weight;
line-height: $input-line-height;
background: $input-bg;
border-color: $input-border-color;
border-width: $input-border-width;
box-shadow: $input-box-shadow;
border-radius: $input-border-radius;
color: $input-color;
padding: $input-padding-y $input-padding-x;
height: $input-height;
resize: none;
&:focus,
:focus-visible {
color: $input-focus-color;
background-color: $input-bg;
border-color: $input-focus-border-color;
box-shadow: $input-focus-box-shadow;
outline: 0;
}
&::placeholder {
color: $input-placeholder-color;
}
}
.datepicker-custom-control_readonly {
border-color: transparent;
background: $input-disabled-bg;
}
.datepicker-custom-control_isInvalid {
border-color: $form-feedback-invalid-color;
}
.datepicker-custom-control-icon {
position: absolute;
z-index: 2;
right: 1.188rem;
top: 50%;
transform: translateY(-50%);
color: $black;
}
}

View File

@@ -1,3 +0,0 @@
.text-black {
color: $black;
}

View File

@@ -1,2 +0,0 @@
$text-color-base: $gray-700;
$text-color-weak: #3E3E3C;

View File

@@ -1,25 +0,0 @@
export const DATE_FORMAT = 'MM/dd/yyyy';
export const TIME_FORMAT = 'HH:mm';
export const DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss\\Z';
export const COMMA_SEPARATED_DATE_FORMAT = 'MMMM D, YYYY';
export const DEFAULT_EMPTY_WYSIWYG_VALUE = '<p>&nbsp;</p>';
export const STATEFUL_BUTTON_STATES = {
pending: 'pending',
default: 'default',
};
export const USER_ROLES = {
admin: 'instructor',
staff: 'staff',
};
export const BADGE_STATES = {
danger: 'danger',
secondary: 'secondary',
};
export const NOTIFICATION_MESSAGES = {
saving: 'Saving',
duplicating: 'Duplicating',
deleting: 'Deleting',
};

View File

@@ -1,163 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl, injectIntl } from '@edx/frontend-platform/i18n';
import {
Button,
Container,
Layout,
} from '@edx/paragon';
import { Add as IconAdd } from '@edx/paragon/icons';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import SubHeader from '../generic/sub-header/SubHeader';
import { USER_ROLES } from '../constants';
import messages from './messages';
import CourseTeamSideBar from './course-team-sidebar/CourseTeamSidebar';
import AddUserForm from './add-user-form/AddUserForm';
import AddTeamMember from './add-team-member/AddTeamMember';
import CourseTeamMember from './course-team-member/CourseTeamMember';
import InfoModal from './info-modal/InfoModal';
import { useCourseTeam } from './hooks';
const CourseTeam = ({ courseId }) => {
const intl = useIntl();
const {
modalType,
errorMessage,
courseName,
currentEmail,
courseTeamUsers,
currentUserEmail,
isLoading,
isSingleAdmin,
isFormVisible,
isQueryPending,
isAllowActions,
isInfoModalOpen,
isOwnershipHint,
isShowAddTeamMember,
isShowInitialSidebar,
isShowUserFilledSidebar,
isInternetConnectionAlertFailed,
openForm,
hideForm,
closeInfoModal,
handleAddUserSubmit,
handleOpenDeleteModal,
handleDeleteUserSubmit,
handleChangeRoleUserSubmit,
handleInternetConnectionFailed,
} = useCourseTeam({ intl, courseId });
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}
return (
<>
<Container size="xl" className="m-4">
<section className="course-team-container mb-4">
<Layout
lg={[{ span: 9 }, { span: 3 }]}
md={[{ span: 9 }, { span: 3 }]}
sm={[{ span: 9 }, { span: 3 }]}
xs={[{ span: 9 }, { span: 3 }]}
xl={[{ span: 9 }, { span: 3 }]}
>
<Layout.Element>
<article>
<div>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={isAllowActions && (
<Button
variant="outline-success"
iconBefore={IconAdd}
size="sm"
onClick={openForm}
disabled={isFormVisible}
>
{intl.formatMessage(messages.addNewMemberButton)}
</Button>
)}
/>
<section className="course-team-section">
<div className="members-container">
{isFormVisible && (
<AddUserForm
onSubmit={handleAddUserSubmit}
onCancel={hideForm}
/>
)}
{courseTeamUsers.length ? courseTeamUsers.map(({ username, role, email }) => (
<CourseTeamMember
key={email}
userName={username}
role={role}
email={email}
currentUserEmail={currentUserEmail || ''}
isAllowActions={isAllowActions}
isHideActions={role === USER_ROLES.admin && isSingleAdmin}
onChangeRole={handleChangeRoleUserSubmit}
onDelete={handleOpenDeleteModal}
/>
)) : null}
{isShowAddTeamMember && (
<AddTeamMember
onFormOpen={openForm}
isButtonDisable={isFormVisible}
/>
)}
</div>
{isShowInitialSidebar && (
<div className="sidebar-container">
<CourseTeamSideBar
courseId={courseId}
isOwnershipHint={isOwnershipHint}
isShowInitialSidebar={isShowInitialSidebar}
/>
</div>
)}
<InfoModal
isOpen={isInfoModalOpen}
close={closeInfoModal}
currentEmail={currentEmail}
errorMessage={errorMessage}
courseName={courseName}
modalType={modalType}
onDeleteSubmit={handleDeleteUserSubmit}
/>
</section>
</div>
</article>
</Layout.Element>
<Layout.Element>
{isShowUserFilledSidebar && (
<CourseTeamSideBar
courseId={courseId}
isOwnershipHint={isOwnershipHint}
/>
)}
</Layout.Element>
</Layout>
</section>
</Container>
<div className="alert-toast">
<InternetConnectionAlert
isFailed={isInternetConnectionAlertFailed}
isQueryPending={isQueryPending}
onInternetConnectionFailed={handleInternetConnectionFailed}
/>
</div>
</>
);
};
CourseTeam.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default injectIntl(CourseTeam);

View File

@@ -1,23 +0,0 @@
@import "./course-team-sidebar/CourseTeamSidebar";
@import "./add-user-form/AddUserForm";
@import "./add-team-member/AddTeamMember";
@import "./course-team-member/CourseTeamMember";
.course-team-section {
.sidebar-container {
width: 30%;
.help-sidebar {
margin-top: 0;
hr {
display: none;
}
}
}
.members-container {
flex-grow: 1;
padding-top: 1.25rem;
}
}

View File

@@ -1,222 +0,0 @@
import React from 'react';
import {
render,
fireEvent,
cleanup,
waitFor,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import initializeStore from '../store';
import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__';
import { getCourseTeamApiUrl, updateCourseTeamUserApiUrl } from './data/api';
import CourseTeam from './CourseTeam';
import messages from './messages';
import { USER_ROLES } from '../constants';
import { executeThunk } from '../utils';
import { changeRoleTeamUserQuery, deleteCourseTeamQuery } from './data/thunk';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseTeam courseId={courseId} />
</IntlProvider>
</AppProvider>
);
describe('<CourseTeam />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('render CourseTeam component with 3 team members correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const {
getByText, getByRole, getByTestId, queryAllByTestId,
} = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
expect(getByTestId('course-team-sidebar')).toBeInTheDocument();
expect(queryAllByTestId('course-team-member')).toHaveLength(3);
});
});
it('render CourseTeam component with 1 team member correctly', async () => {
cleanup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
const {
getByText, getByRole, getByTestId, getAllByTestId,
} = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
expect(getByTestId('course-team-sidebar')).toBeInTheDocument();
expect(getAllByTestId('course-team-member')).toHaveLength(1);
});
});
it('render CourseTeam component without team member correctly', async () => {
cleanup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithoutUsers);
const {
getByText, getByRole, getByTestId, queryAllByTestId,
} = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
expect(getByTestId('course-team-sidebar__initial')).toBeInTheDocument();
expect(queryAllByTestId('course-team-member')).toHaveLength(0);
});
});
it('render CourseTeam component with initial sidebar correctly', async () => {
cleanup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithoutUsers);
const { getByTestId, queryByTestId } = render(<RootWrapper />);
await waitFor(() => {
expect(getByTestId('course-team-sidebar__initial')).toBeInTheDocument();
expect(queryByTestId('course-team-sidebar')).not.toBeInTheDocument();
});
});
it('render CourseTeam component without initial sidebar correctly', async () => {
cleanup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const { getByTestId, queryByTestId } = render(<RootWrapper />);
await waitFor(() => {
expect(queryByTestId('course-team-sidebar__initial')).not.toBeInTheDocument();
expect(getByTestId('course-team-sidebar')).toBeInTheDocument();
});
});
it('displays AddUserForm when clicking the "Add New Member" button', async () => {
cleanup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
const { getByRole, queryByTestId } = render(<RootWrapper />);
await waitFor(() => {
expect(queryByTestId('add-user-form')).not.toBeInTheDocument();
const addButton = getByRole('button', { name: messages.addNewMemberButton.defaultMessage });
fireEvent.click(addButton);
expect(queryByTestId('add-user-form')).toBeInTheDocument();
});
});
it('displays AddUserForm when clicking the "Add a New Team member" button', async () => {
cleanup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
const { getByRole, queryByTestId } = render(<RootWrapper />);
await waitFor(() => {
expect(queryByTestId('add-user-form')).not.toBeInTheDocument();
const addButton = getByRole('button', { name: 'Add a new team member' });
fireEvent.click(addButton);
expect(queryByTestId('add-user-form')).toBeInTheDocument();
});
});
it('not displays "Add New Member" and AddTeamMember component when isAllowActions is false', async () => {
cleanup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, {
...courseTeamWithOneUser,
allowActions: false,
});
const { queryByRole, queryByTestId } = render(<RootWrapper />);
await waitFor(() => {
expect(queryByRole('button', { name: messages.addNewMemberButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByTestId('add-team-member')).not.toBeInTheDocument();
});
});
it('should delete user', async () => {
cleanup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const { queryByText } = render(<RootWrapper />);
axiosMock
.onDelete(updateCourseTeamUserApiUrl(courseId, 'staff@example.com'))
.reply(200);
await executeThunk(deleteCourseTeamQuery(courseId, 'staff@example.com'), store.dispatch);
expect(queryByText('staff@example.com')).not.toBeInTheDocument();
});
it('should change role user', async () => {
cleanup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const { getAllByText } = render(<RootWrapper />);
axiosMock
.onPut(updateCourseTeamUserApiUrl(courseId, 'staff@example.com', { role: USER_ROLES.admin }))
.reply(200, { role: USER_ROLES.admin });
await executeThunk(changeRoleTeamUserQuery(courseId, 'staff@example.com', { role: USER_ROLES.admin }), store.dispatch);
expect(getAllByText('Admin')).toHaveLength(1);
});
});

View File

@@ -1,24 +0,0 @@
module.exports = {
showTransferOwnershipHint: true,
users: [
{
email: 'staff@example.com',
id: '2',
role: 'staff',
username: 'Staff_Name',
},
{
email: 'audit@example.com',
id: '3',
role: 'staff',
username: 'Audit_Name',
},
{
email: 'vladislav.keblysh@raccoongang.com',
id: '1',
role: 'instructor',
username: 'Vladislav_Keblysh',
},
],
allowActions: true,
};

View File

@@ -1,12 +0,0 @@
module.exports = {
showTransferOwnershipHint: true,
users: [
{
email: 'staff@example.com',
id: '2',
role: 'staff',
username: 'Staff_Name',
},
],
allowActions: true,
};

View File

@@ -1,5 +0,0 @@
module.exports = {
showTransferOwnershipHint: true,
users: [],
allowActions: true,
};

View File

@@ -1,3 +0,0 @@
export { default as courseTeamMock } from './courseTeam';
export { default as courseTeamWithOneUser } from './courseTeamWithOneUser';
export { default as courseTeamWithoutUsers } from './courseTeamWithoutUsers';

View File

@@ -1,39 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Add as IconAdd } from '@edx/paragon/icons';
import { Button } from '@edx/paragon';
import messages from './messages';
const AddTeamMember = ({ onFormOpen, isButtonDisable }) => {
const intl = useIntl();
return (
<div className="add-team-member bg-gray-100" data-testid="add-team-member">
<div className="add-team-member-info">
<h3 className="add-team-member-title font-weight-bold">{intl.formatMessage(messages.title)}</h3>
<span className="text-gray-500 small">{intl.formatMessage(messages.description)}</span>
</div>
<Button
variant="outline-success"
iconBefore={IconAdd}
onClick={onFormOpen}
disabled={isButtonDisable}
>
{intl.formatMessage(messages.button)}
</Button>
</div>
);
};
AddTeamMember.propTypes = {
onFormOpen: PropTypes.func.isRequired,
isButtonDisable: PropTypes.bool,
};
AddTeamMember.defaultProps = {
isButtonDisable: false,
};
export default AddTeamMember;

View File

@@ -1,17 +0,0 @@
.add-team-member {
display: flex;
align-items: center;
justify-content: space-between;
border: .0625rem solid $gray-200;
border-radius: .375rem;
box-shadow: inset inset 0 1px .125rem 1px $gray-200;
padding: 1.25rem 1.875rem;
.add-team-member-info {
width: 60%;
}
.add-team-member-title {
font-size: $spacer;
}
}

View File

@@ -1,46 +0,0 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import AddTeamMember from './AddTeamMember';
import messages from './messages';
const onFormOpenMock = jest.fn();
const renderComponent = (props) => render(
<IntlProvider locale="en">
<AddTeamMember onFormOpen={onFormOpenMock} {...props} />
</IntlProvider>,
);
describe('<AddTeamMember />', () => {
it('render AddTeamMember component correctly', () => {
const { getByText, getByRole } = renderComponent();
expect(getByText(messages.title.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.description.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.button.defaultMessage })).toBeInTheDocument();
});
it('calls onFormOpen when the button is clicked', () => {
const { getByText } = renderComponent();
const addButton = getByText(messages.button.defaultMessage);
fireEvent.click(addButton);
expect(onFormOpenMock).toHaveBeenCalledTimes(1);
});
it('"Add a New Team member" button is disabled when isButtonDisable is true', () => {
const { getByRole } = renderComponent({ isButtonDisable: true });
const addButton = getByRole('button', { name: messages.button.defaultMessage });
expect(addButton).toBeDisabled();
});
it('"Add a New Team member" button is not disabled when isButtonDisable is false', () => {
const { getByRole } = renderComponent();
const addButton = getByRole('button', { name: messages.button.defaultMessage });
expect(addButton).not.toBeDisabled();
});
});

View File

@@ -1,18 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
title: {
id: 'course-authoring.course-team.add-team-member.title',
defaultMessage: 'Add team members to this course',
},
description: {
id: 'course-authoring.course-team.add-team-member.description',
defaultMessage: 'Adding team members makes course authoring collaborative. Users must be signed up for Studio and have an active account.',
},
button: {
id: 'course-authoring.course-team.add-team-member.button',
defaultMessage: 'Add a new team member',
},
});
export default messages;

View File

@@ -1,66 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
Form,
ActionRow,
} from '@edx/paragon';
import { Formik } from 'formik';
import messages from './messages';
import FormikControl from '../../generic/FormikControl';
import { EXAMPLE_USER_EMAIL } from '../constants';
const AddUserForm = ({ onSubmit, onCancel }) => {
const intl = useIntl();
return (
<div className="add-user-form" data-testid="add-user-form">
<Formik
initialValues={{ email: '' }}
onSubmit={onSubmit}
validateOnBlur
>
{({ handleSubmit, values }) => (
<>
<Form.Group size="sm" className="form-field">
<h3 className="form-title">{intl.formatMessage(messages.formTitle)}</h3>
<Form.Label size="sm" className="form-label font-weight-bold">
{intl.formatMessage(messages.formLabel)}
</Form.Label>
<FormikControl
name="email"
value={values.email}
placeholder={intl.formatMessage(messages.formPlaceholder, { email: EXAMPLE_USER_EMAIL })}
/>
<Form.Control.Feedback className="form-helper-text">
{intl.formatMessage(messages.formHelperText)}
</Form.Control.Feedback>
</Form.Group>
<ActionRow>
<Button variant="tertiary" size="sm" onClick={onCancel}>
{intl.formatMessage(messages.cancelButton)}
</Button>
<Button
size="sm"
onClick={handleSubmit}
disabled={!values.email.length}
type="submit"
>
{intl.formatMessage(messages.addUserButton)}
</Button>
</ActionRow>
</>
)}
</Formik>
</div>
);
};
AddUserForm.propTypes = {
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
};
export default AddUserForm;

View File

@@ -1,42 +0,0 @@
.add-user-form {
display: flex;
flex-direction: column;
border: .0625rem solid $gray-200;
border-radius: .375rem;
box-shadow: 0 1px 1px $gray-200;
margin-bottom: 1.25rem;
background-color: $white;
.form-title {
font-size: 1.5rem;
margin-bottom: 1.875rem;
}
.form-field {
padding: 1.25rem 1.875rem;
margin-bottom: $spacer;
.pgn__form-group {
margin-bottom: 0;
}
}
.form-label {
position: relative;
&::after {
margin-left: .3125rem;
content: "*";
}
}
.form-helper-text {
font-size: $font-size-xs;
}
.pgn__action-row {
padding: $spacer 1.875rem;
border-top: .0625rem solid $gray-200;
justify-content: flex-start;
}
}

View File

@@ -1,128 +0,0 @@
import React from 'react';
import {
render,
fireEvent,
act,
waitFor,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { EXAMPLE_USER_EMAIL } from '../constants';
import initializeStore from '../../store';
import { USER_ROLES } from '../../constants';
import { updateCourseTeamUserApiUrl } from '../data/api';
import { createCourseTeamQuery } from '../data/thunk';
import { executeThunk } from '../../utils';
import AddUserForm from './AddUserForm';
import messages from './messages';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const onSubmitMock = jest.fn();
const onCancelMock = jest.fn();
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en">
<AddUserForm
onSubmit={onSubmitMock}
onCancel={onCancelMock}
/>
</IntlProvider>
</AppProvider>
);
describe('<AddUserForm />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('render AddUserForm component correctly', () => {
const { getByText, getByPlaceholderText } = render(<RootWrapper />);
expect(getByText(messages.formTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.formLabel.defaultMessage)).toBeInTheDocument();
expect(getByPlaceholderText(messages.formPlaceholder.defaultMessage
.replace('{email}', EXAMPLE_USER_EMAIL))).toBeInTheDocument();
expect(getByText(messages.cancelButton.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.addUserButton.defaultMessage)).toBeInTheDocument();
});
it('calls onSubmit when the "Add User" button is clicked with a valid email', async () => {
const { getByPlaceholderText, getByRole } = render(<RootWrapper />);
const emailInput = getByPlaceholderText(messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL));
const addUserButton = getByRole('button', { name: messages.addUserButton.defaultMessage });
fireEvent.change(emailInput, { target: { value: EXAMPLE_USER_EMAIL } });
await act(async () => {
fireEvent.click(addUserButton);
});
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalledTimes(1);
expect(onSubmitMock).toHaveBeenCalledWith(
{ email: EXAMPLE_USER_EMAIL },
expect.objectContaining({ submitForm: expect.any(Function) }),
);
});
axiosMock
.onPost(updateCourseTeamUserApiUrl(courseId, EXAMPLE_USER_EMAIL), { role: USER_ROLES.staff })
.reply(200, { role: USER_ROLES.staff });
await executeThunk(createCourseTeamQuery(courseId, EXAMPLE_USER_EMAIL), store.dispatch);
});
it('calls onCancel when the "Cancel" button is clicked', () => {
const { getByText } = render(<RootWrapper />);
const cancelButton = getByText(messages.cancelButton.defaultMessage);
fireEvent.click(cancelButton);
expect(onCancelMock).toHaveBeenCalledTimes(1);
});
it('"Add User" button is disabled when the email input field is empty', () => {
const { getByText } = render(<RootWrapper />);
const addUserButton = getByText(messages.addUserButton.defaultMessage);
expect(addUserButton).toBeDisabled();
});
it('"Add User" button is not disabled when the email input field is not empty', () => {
const { getByPlaceholderText, getByText } = render(<RootWrapper />);
const emailInput = getByPlaceholderText(
messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL),
);
const addUserButton = getByText(messages.addUserButton.defaultMessage);
fireEvent.change(emailInput, { target: { value: 'user@example.com' } });
expect(addUserButton).not.toBeDisabled();
});
});

View File

@@ -1,31 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
formTitle: {
id: 'course-authoring.course-team.form.title',
defaultMessage: 'Add a user to your course\'s team',
},
formLabel: {
id: 'course-authoring.course-team.form.label',
defaultMessage: 'User\'s email address',
},
formPlaceholder: {
id: 'course-authoring.course-team.form.placeholder',
defaultMessage: 'example: {email}',
},
formHelperText: {
id: 'course-authoring.course-team.form.helperText',
defaultMessage: 'Provide the email address of the user you want to add as Staff',
},
addUserButton: {
id: 'course-authoring.course-team.form.button.addUser',
defaultMessage: 'Add user',
},
cancelButton: {
id: 'course-authoring.course-team.form.button.cancel',
defaultMessage: 'Cancel',
},
});
export default messages;

View File

@@ -1,7 +0,0 @@
export const MODAL_TYPES = {
error: 'error',
delete: 'delete',
warning: 'warning',
};
export const EXAMPLE_USER_EMAIL = 'username@domain.com';

View File

@@ -1,78 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Badge, Button, MailtoLink } from '@edx/paragon';
import { DeleteOutline as DeleteOutlineIcon } from '@edx/paragon/icons';
import messages from './messages';
import { USER_ROLES, BADGE_STATES } from '../../constants';
const CourseTeamMember = ({
userName,
role,
email,
onChangeRole,
onDelete,
currentUserEmail,
isHideActions,
isAllowActions,
}) => {
const intl = useIntl();
const isAdminRole = role === USER_ROLES.admin;
return (
<div className="course-team-member" data-testid="course-team-member">
<div className="member-info">
<Badge variant={isAdminRole ? BADGE_STATES.danger : BADGE_STATES.secondary} className="badge-current-user">
{isAdminRole
? intl.formatMessage(messages.roleAdmin)
: intl.formatMessage(messages.roleStaff)}
{currentUserEmail === email && (
<span className="badge-current-user">{intl.formatMessage(messages.roleYou)}</span>
)}
</Badge>
<span className="member-info-name font-weight-bold">{userName}</span>
<MailtoLink to={email}>{email}</MailtoLink>
</div>
{/* eslint-disable-next-line no-nested-ternary */}
{isAllowActions && (
!isHideActions ? (
<div className="member-actions">
<Button
variant={isAdminRole ? 'tertiary' : 'primary'}
size="sm"
onClick={() => onChangeRole(email, isAdminRole ? USER_ROLES.staff : USER_ROLES.admin)}
>
{isAdminRole ? intl.formatMessage(messages.removeButton) : intl.formatMessage(messages.addButton)}
</Button>
<Button
className="delete-button"
variant="tertiary"
size="sm"
data-testid="delete-button"
iconBefore={DeleteOutlineIcon}
onClick={() => onDelete(email)}
/>
</div>
) : (
<div className="member-hint text-right">
<span>{intl.formatMessage(messages.hint)}</span>
</div>
)
)}
</div>
);
};
CourseTeamMember.propTypes = {
userName: PropTypes.string.isRequired,
role: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
onChangeRole: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
currentUserEmail: PropTypes.string.isRequired,
isHideActions: PropTypes.bool.isRequired,
isAllowActions: PropTypes.bool.isRequired,
};
export default CourseTeamMember;

View File

@@ -1,63 +0,0 @@
.course-team-container {
margin-top: 3rem;
}
.course-team-member {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
padding: 1.563rem 1.875rem 1.25rem;
background-color: $white;
border: .0625rem solid $gray-200;
border-radius: .375rem;
box-shadow: 0 1px 1px $gray-200;
&:not(:last-child) {
margin-bottom: 1.25rem;
}
.member-info {
position: relative;
display: flex;
flex-direction: column;
.badge {
position: absolute;
top: -2.25rem;
left: -.25rem;
}
.badge-current-user {
color: $gray-100;
margin-left: .25rem;
}
.member-info-name {
font-size: 1.5rem;
margin-bottom: .25rem;
}
}
.member-hint {
width: 45%;
margin-right: 3.875rem;
color: $gray-300;
font-size: $font-size-sm;
}
.member-actions {
display: flex;
gap: $spacer;
.delete-button {
display: flex;
align-items: center;
justify-content: center;
& > span {
margin: 0;
}
}
}
}

View File

@@ -1,91 +0,0 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { USER_ROLES } from '../../constants';
import CourseTeamMember from './CourseTeamMember';
import messages from './messages';
const userNameMock = 'User';
const emailMock = 'user@example.com';
const currentUserEmailMock = 'user@example.com';
const onChangeRoleMock = jest.fn();
const onDeleteMock = jest.fn();
const renderComponent = (props) => render(
<IntlProvider locale="en">
<CourseTeamMember
userName={userNameMock}
email={emailMock}
currentUserEmail="some@example.com"
onChangeRole={onChangeRoleMock}
onDelete={onDeleteMock}
isAllowActions
isHideActions={false}
role={USER_ROLES.admin}
{...props}
/>
</IntlProvider>,
);
describe('<CourseTeamMember />', () => {
it('render CourseTeamMember component correctly', () => {
const { getByText, getByRole, getByTestId } = renderComponent();
expect(getByText(userNameMock)).toBeInTheDocument();
expect(getByText(emailMock)).toBeInTheDocument();
expect(getByRole('button', { name: messages.removeButton.defaultMessage })).toBeInTheDocument();
expect(getByTestId('delete-button')).toBeInTheDocument();
expect(getByText(messages.roleAdmin.defaultMessage)).toBeInTheDocument();
});
it('displays correct badge and "You" label for the current user', () => {
const { getByText } = renderComponent({
role: USER_ROLES.staff,
currentUserEmail: currentUserEmailMock,
});
expect(getByText(messages.roleStaff.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.roleYou.defaultMessage)).toBeInTheDocument();
});
it('not displays actions when isAllowActions is false', () => {
const { queryByRole, queryByTestId } = renderComponent({
role: USER_ROLES.admin,
currentUserEmail: currentUserEmailMock,
isAllowActions: false,
});
expect(queryByRole('button', { name: messages.removeButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByTestId('delete-button')).not.toBeInTheDocument();
});
it('calls onChangeRole when the "Add"/"Remove" button is clicked', () => {
const { getByRole } = renderComponent({
role: USER_ROLES.staff,
});
const addButton = getByRole('button', { name: messages.addButton.defaultMessage });
fireEvent.click(addButton);
expect(onChangeRoleMock).toHaveBeenCalledTimes(1);
expect(onChangeRoleMock).toHaveBeenCalledWith(emailMock, USER_ROLES.admin);
});
it('calls onDelete when the "Delete" button is clicked', () => {
const { getByTestId } = renderComponent();
const deleteButton = getByTestId('delete-button');
fireEvent.click(deleteButton);
expect(onDeleteMock).toHaveBeenCalledTimes(1);
});
it('renders the "Hint" when isHideActions is true', () => {
const { getByText, queryByRole, queryByTestId } = renderComponent({
isHideActions: true,
});
expect(queryByRole('button', { name: messages.addButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByTestId('delete-button')).not.toBeInTheDocument();
expect(getByText(messages.hint.defaultMessage)).toBeInTheDocument();
});
});

View File

@@ -1,30 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
roleAdmin: {
id: 'course-authoring.course-team.member.role.admin',
defaultMessage: 'Admin',
},
roleStaff: {
id: 'course-authoring.course-team.member.role.staff',
defaultMessage: 'Staff',
},
roleYou: {
id: 'course-authoring.course-team.member.role.you',
defaultMessage: 'You!',
},
hint: {
id: 'course-authoring.course-team.member.hint',
defaultMessage: 'Promote another member to Admin to remove your admin rights',
},
addButton: {
id: 'course-authoring.course-team.member.button.add',
defaultMessage: 'Add admin access',
},
removeButton: {
id: 'course-authoring.course-team.member.button.remove',
defaultMessage: 'Remove admin access',
},
});
export default messages;

View File

@@ -1,52 +0,0 @@
import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import CourseTeamSidebar from './CourseTeamSidebar';
import messages from './messages';
import initializeStore from '../../store';
const courseId = 'course-123';
let store;
const renderComponent = (props) => render(
<AppProvider store={store} messages={{}}>
<IntlProvider locale="en">
<CourseTeamSidebar courseId={courseId} {...props} />
</IntlProvider>
</AppProvider>,
);
describe('<CourseTeamSidebar />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('render CourseTeamSidebar component correctly', () => {
const { getByText } = renderComponent();
expect(getByText(messages.sidebarTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sidebarAbout_1.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sidebarAbout_2.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sidebarAbout_3.defaultMessage)).toBeInTheDocument();
});
it('render CourseTeamSidebar when isOwnershipHint is true', () => {
const { getByText } = renderComponent({ isOwnershipHint: true });
expect(getByText(messages.ownershipTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(
'Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click to make another user the Admin, then ask that user to remove you from the Course Team list.',
)).toBeInTheDocument();
});
});

View File

@@ -1,68 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import HelpSidebar from '../../generic/help-sidebar';
import messages from './messages';
const CourseTeamSideBar = ({ courseId, isOwnershipHint, isShowInitialSidebar }) => {
const intl = useIntl();
return (
<div
className="course-team-sidebar"
data-testid={isShowInitialSidebar ? 'course-team-sidebar__initial' : 'course-team-sidebar'}
>
<HelpSidebar
intl={intl}
courseId={courseId}
showOtherSettings={false}
>
<h4 className="help-sidebar-about-title">
{intl.formatMessage(messages.sidebarTitle)}
</h4>
<p className="help-sidebar-about-descriptions">
{intl.formatMessage(messages.sidebarAbout_1)}
</p>
<p className="help-sidebar-about-descriptions">
{intl.formatMessage(messages.sidebarAbout_2)}
</p>
<p className="help-sidebar-about-descriptions">
{intl.formatMessage(messages.sidebarAbout_3)}
</p>
</HelpSidebar>
{isOwnershipHint && (
<>
<hr />
<HelpSidebar
intl={intl}
courseId={courseId}
showOtherSettings={false}
>
<h4 className="help-sidebar-about-title">
{intl.formatMessage(messages.ownershipTitle)}
</h4>
<p className="help-sidebar-about-descriptions">
{intl.formatMessage(
messages.ownershipDescription,
{ strong: <strong>{intl.formatMessage(messages.addAdminAccess)}</strong> },
)}
</p>
</HelpSidebar>
</>
)}
</div>
);
};
CourseTeamSideBar.defaultProps = {
isShowInitialSidebar: false,
};
CourseTeamSideBar.propTypes = {
courseId: PropTypes.string.isRequired,
isOwnershipHint: PropTypes.bool.isRequired,
isShowInitialSidebar: PropTypes.bool,
};
export default CourseTeamSideBar;

View File

@@ -1,11 +0,0 @@
.course-team-sidebar {
.help-sidebar {
&:not(:first-child) {
margin-top: 0;
}
hr {
display: none;
}
}
}

View File

@@ -1,34 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
sidebarTitle: {
id: 'course-authoring.course-team.sidebar.title',
defaultMessage: 'Course team roles',
},
sidebarAbout_1: {
id: 'course-authoring.course-team.sidebar.about-1',
defaultMessage: 'Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.',
},
sidebarAbout_2: {
id: 'course-authoring.course-team.sidebar.about-2',
defaultMessage: 'Admins are course team members who can add and remove other course team members.',
},
sidebarAbout_3: {
id: 'course-authoring.course-team.sidebar.about-3',
defaultMessage: 'All course team members can access content in Studio, the LMS, and Insights, but are not automatically enrolled in the course.',
},
ownershipTitle: {
id: 'course-authoring.course-team.sidebar.ownership.title',
defaultMessage: 'Transferring ownership',
},
ownershipDescription: {
id: 'course-authoring.course-team.sidebar.ownership.description',
defaultMessage: 'Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click {strong} to make another user the Admin, then ask that user to remove you from the Course Team list.',
},
addAdminAccess: {
id: 'course-authoring.course-team.sidebar.ownership.addAdminAccess',
defaultMessage: 'Add admin access',
},
});
export default messages;

View File

@@ -1,54 +0,0 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { USER_ROLES } from '../../constants';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseTeamApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_team/${courseId}`;
export const updateCourseTeamUserApiUrl = (courseId, email) => `${getApiBaseUrl()}/course_team/${courseId}/${email}`;
/**
* Get course team.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getCourseTeam(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseTeamApiUrl(courseId));
return camelCaseObject(data);
}
/**
* Create course team user.
* @param {string} courseId
* @param {string} email
* @returns {Promise<Object>}
*/
export async function createTeamUser(courseId, email) {
await getAuthenticatedHttpClient()
.post(updateCourseTeamUserApiUrl(courseId, email), { role: USER_ROLES.staff });
}
/**
* Change role course team user.
* @param {string} courseId
* @param {string} email
* @param {string} role
* @returns {Promise<Object>}
*/
export async function changeRoleTeamUser(courseId, email, role) {
await getAuthenticatedHttpClient()
.put(updateCourseTeamUserApiUrl(courseId, email), { role });
}
/**
* Delete course team user.
* @param {string} courseId
* @param {string} email
* @returns {Promise<Object>}
*/
export async function deleteTeamUser(courseId, email) {
await getAuthenticatedHttpClient()
.delete(updateCourseTeamUserApiUrl(courseId, email));
}

View File

@@ -1,6 +0,0 @@
export const getCourseTeamUsers = (state) => state.courseTeam.users;
export const getCourseTeamLoadingStatus = (state) => state.courseTeam.loadingCourseTeamStatus;
export const getErrorMessage = (state) => state.courseTeam.errorMessage;
export const getIsAllowActions = (state) => state.courseTeam.allowActions;
export const getIsOwnershipHint = (state) => state.courseTeam.showTransferOwnershipHint;
export const getSavingStatus = (state) => state.courseTeam.savingStatus;

View File

@@ -1,46 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../data/constants';
const slice = createSlice({
name: 'courseTeam',
initialState: {
loadingCourseTeamStatus: RequestStatus.IN_PROGRESS,
savingStatus: '',
users: [],
showTransferOwnershipHint: false,
allowActions: false,
errorMessage: '',
},
reducers: {
fetchCourseTeamSuccess: (state, { payload }) => {
state.users = payload.users;
state.showTransferOwnershipHint = payload.showTransferOwnershipHint;
state.allowActions = payload.allowActions;
},
updateLoadingCourseTeamStatus: (state, { payload }) => {
state.loadingCourseTeamStatus = payload.status;
},
deleteCourseTeamUser: (state, { payload }) => {
state.users = state.users.filter((user) => user.email !== payload);
},
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
setErrorMessage: (state, { payload }) => {
state.errorMessage = payload;
},
},
});
export const {
fetchCourseTeamSuccess,
updateLoadingCourseTeamStatus,
deleteCourseTeamUser,
updateSavingStatus,
setErrorMessage,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -1,87 +0,0 @@
import { RequestStatus } from '../../data/constants';
import {
getCourseTeam,
deleteTeamUser,
createTeamUser,
changeRoleTeamUser,
} from './api';
import {
fetchCourseTeamSuccess,
updateLoadingCourseTeamStatus,
deleteCourseTeamUser,
updateSavingStatus,
setErrorMessage,
} from './slice';
export function fetchCourseTeamQuery(courseId) {
return async (dispatch) => {
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const courseTeam = await getCourseTeam(courseId);
dispatch(fetchCourseTeamSuccess(courseTeam));
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function createCourseTeamQuery(courseId, email) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await createTeamUser(courseId, email);
const courseTeam = await getCourseTeam(courseId);
dispatch(fetchCourseTeamSuccess(courseTeam));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
const message = error?.response?.data?.error || '';
dispatch(setErrorMessage(message));
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function changeRoleTeamUserQuery(courseId, email, role) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await changeRoleTeamUser(courseId, email, role);
const courseTeam = await getCourseTeam(courseId);
dispatch(fetchCourseTeamSuccess(courseTeam));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch ({ message }) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function deleteCourseTeamQuery(courseId, email) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await deleteTeamUser(courseId, email);
dispatch(deleteCourseTeamUser(email));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}

View File

@@ -1,139 +0,0 @@
import { useDispatch, useSelector } from 'react-redux';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useEffect, useState } from 'react';
import { useToggle } from '@edx/paragon';
import { USER_ROLES } from '../constants';
import { RequestStatus } from '../data/constants';
import { useModel } from '../generic/model-store';
import {
changeRoleTeamUserQuery,
createCourseTeamQuery,
deleteCourseTeamQuery,
fetchCourseTeamQuery,
} from './data/thunk';
import {
getCourseTeamLoadingStatus,
getCourseTeamUsers,
getErrorMessage,
getIsAllowActions,
getIsOwnershipHint, getSavingStatus,
} from './data/selectors';
import { setErrorMessage } from './data/slice';
import { MODAL_TYPES } from './constants';
const useCourseTeam = ({ courseId }) => {
const dispatch = useDispatch();
const { email: currentUserEmail } = getAuthenticatedUser();
const courseDetails = useModel('courseDetails', courseId);
const [modalType, setModalType] = useState(MODAL_TYPES.delete);
const [isInfoModalOpen, openInfoModal, closeInfoModal] = useToggle(false);
const [isFormVisible, openForm, hideForm] = useToggle(false);
const [currentEmail, setCurrentEmail] = useState('');
const [isQueryPending, setIsQueryPending] = useState(false);
const courseTeamUsers = useSelector(getCourseTeamUsers);
const errorMessage = useSelector(getErrorMessage);
const savingStatus = useSelector(getSavingStatus);
const isAllowActions = useSelector(getIsAllowActions);
const isOwnershipHint = useSelector(getIsOwnershipHint);
const loadingCourseTeamStatus = useSelector(getCourseTeamLoadingStatus);
const isSingleAdmin = courseTeamUsers.filter((user) => user.role === USER_ROLES.admin).length === 1;
const handleOpenInfoModal = (type, email) => {
setCurrentEmail(email);
setModalType(type);
openInfoModal();
};
const handleCloseInfoModal = () => {
dispatch(setErrorMessage(''));
closeInfoModal();
};
const handleAddUserSubmit = (data) => {
setIsQueryPending(true);
const { email } = data;
const isUserContains = courseTeamUsers.some((user) => user.email === email);
if (isUserContains) {
handleOpenInfoModal(MODAL_TYPES.warning, email);
return;
}
dispatch(createCourseTeamQuery(courseId, email)).then((result) => {
if (result) {
hideForm();
dispatch(setErrorMessage(''));
return;
}
handleOpenInfoModal(MODAL_TYPES.error, email);
});
};
const handleDeleteUserSubmit = () => {
setIsQueryPending(true);
dispatch(deleteCourseTeamQuery(courseId, currentEmail));
handleCloseInfoModal();
};
const handleChangeRoleUserSubmit = (email, role) => {
setIsQueryPending(true);
dispatch(changeRoleTeamUserQuery(courseId, email, role));
};
const handleInternetConnectionFailed = () => {
setIsQueryPending(false);
};
const handleOpenDeleteModal = (email) => {
handleOpenInfoModal(MODAL_TYPES.delete, email);
};
useEffect(() => {
dispatch(fetchCourseTeamQuery(courseId));
}, [courseId]);
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
setIsQueryPending(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [savingStatus]);
return {
modalType,
errorMessage,
courseName: courseDetails?.name || '',
currentEmail,
courseTeamUsers,
currentUserEmail,
isLoading: loadingCourseTeamStatus === RequestStatus.IN_PROGRESS,
isSingleAdmin,
isFormVisible,
isAllowActions,
isInfoModalOpen,
isOwnershipHint,
isQueryPending,
isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED,
isShowAddTeamMember: courseTeamUsers.length === 1 && isAllowActions,
isShowInitialSidebar: !courseTeamUsers.length && !isFormVisible,
isShowUserFilledSidebar: Boolean(courseTeamUsers.length) || isFormVisible,
openForm,
hideForm,
closeInfoModal,
handleAddUserSubmit,
handleOpenInfoModal,
handleOpenDeleteModal,
handleDeleteUserSubmit,
handleChangeRoleUserSubmit,
handleInternetConnectionFailed,
};
};
// eslint-disable-next-line import/prefer-default-export
export { useCourseTeam };

View File

@@ -1,75 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
ActionRow,
Button,
AlertModal,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { MODAL_TYPES } from '../constants';
import { getInfoModalSettings } from '../utils';
const InfoModal = ({
modalType,
isOpen,
close,
onDeleteSubmit,
currentEmail,
errorMessage,
courseName,
}) => {
const intl = useIntl();
const {
title,
message,
variant,
closeButtonText,
submitButtonText,
closeButtonVariant,
} = getInfoModalSettings(modalType, currentEmail, errorMessage, courseName, intl);
const isEmptyErrorMessage = modalType === MODAL_TYPES.error && !errorMessage;
return (
<AlertModal
title={title}
variant={variant}
isOpen={isOpen && !isEmptyErrorMessage}
onClose={close}
footerNode={(
<ActionRow>
<Button variant={closeButtonVariant} onClick={close}>
{closeButtonText}
</Button>
{modalType === MODAL_TYPES.delete && (
<Button
variant="danger"
onClick={(e) => {
e.preventDefault();
onDeleteSubmit();
}}
>
{submitButtonText}
</Button>
)}
</ActionRow>
)}
>
<p>{message}</p>
</AlertModal>
);
};
InfoModal.propTypes = {
modalType: PropTypes.string.isRequired,
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
currentEmail: PropTypes.string.isRequired,
errorMessage: PropTypes.string.isRequired,
courseName: PropTypes.string.isRequired,
onDeleteSubmit: PropTypes.func.isRequired,
};
export default InfoModal;

View File

@@ -1,85 +0,0 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { MODAL_TYPES } from '../constants';
import InfoModal from './InfoModal';
import messages from './messages';
const closeMock = jest.fn();
const onDeleteSubmitMock = jest.fn();
const currentEmailMock = 'user@example.com';
const errorMessageMock = 'Error text error@example.com';
const courseNameMock = 'Course Name';
const renderComponent = (props) => render(
<IntlProvider locale="en">
<InfoModal
modalType={MODAL_TYPES.delete}
isOpen
close={closeMock}
onDeleteSubmit={onDeleteSubmitMock}
currentEmail={currentEmailMock}
errorMessage={errorMessageMock}
courseName={courseNameMock}
{...props}
/>
</IntlProvider>,
);
describe('<InfoModal />', () => {
it('render InfoModal component with type delete correctly', () => {
const { getByText, getByRole } = renderComponent({
modalType: MODAL_TYPES.delete,
});
expect(getByText(messages.deleteModalTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(
messages.deleteModalMessage.defaultMessage
.replace('{email}', currentEmailMock)
.replace('{courseName}', courseNameMock),
)).toBeInTheDocument();
expect(getByRole('button', { name: messages.deleteModalCancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.deleteModalDeleteButton.defaultMessage })).toBeInTheDocument();
});
it('render InfoModal component with type error correctly', () => {
const { getByText, getByRole } = renderComponent({
modalType: MODAL_TYPES.error,
});
expect(getByText(messages.errorModalTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(errorMessageMock)).toBeInTheDocument();
expect(getByRole('button', { name: messages.errorModalOkButton.defaultMessage })).toBeInTheDocument();
});
it('render InfoModal component with type warning correctly', () => {
const { getByText, getByRole } = renderComponent({
modalType: MODAL_TYPES.warning,
});
expect(getByText(messages.warningModalTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(
messages.warningModalMessage.defaultMessage
.replace('{email}', currentEmailMock)
.replace('{courseName}', courseNameMock),
)).toBeInTheDocument();
expect(getByRole('button', { name: messages.warningModalReturnButton.defaultMessage })).toBeInTheDocument();
});
it('calls close handler when the close button is clicked', () => {
const { getByRole } = renderComponent();
const closeButton = getByRole('button', { name: messages.deleteModalCancelButton.defaultMessage });
fireEvent.click(closeButton);
expect(closeMock).toHaveBeenCalledTimes(1);
});
it('calls onDeleteSubmit handler when the delete button is clicked', () => {
const { getByRole } = renderComponent();
const deleteButton = getByRole('button', { name: messages.deleteModalDeleteButton.defaultMessage });
fireEvent.click(deleteButton);
expect(onDeleteSubmitMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,42 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
deleteModalTitle: {
id: 'course-authoring.course-team.member.button.remove',
defaultMessage: 'Are you sure?',
},
deleteModalMessage: {
id: 'course-authoring.course-team.delete-modal.message',
defaultMessage: 'Are you sure you want to delete {email} from the course team for “{courseName}”?',
},
deleteModalDeleteButton: {
id: 'course-authoring.course-team.delete-modal.button.delete',
defaultMessage: 'Delete',
},
deleteModalCancelButton: {
id: 'course-authoring.course-team.delete-modal.button.cancel',
defaultMessage: 'Cancel',
},
errorModalTitle: {
id: 'course-authoring.course-team.error-modal.title',
defaultMessage: 'Error adding user',
},
errorModalOkButton: {
id: 'course-authoring.course-team.error-modal.button.ok',
defaultMessage: 'Ok',
},
warningModalTitle: {
id: 'course-authoring.course-team.warning-modal.title',
defaultMessage: 'Already a course team member',
},
warningModalMessage: {
id: 'course-authoring.course-team.warning-modal.message',
defaultMessage: '{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.',
},
warningModalReturnButton: {
id: 'course-authoring.course-team.warning-modal.button.return',
defaultMessage: 'Return to team listing',
},
});
export default messages;

View File

@@ -1,18 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
headingTitle: {
id: 'course-authoring.course-team.headingTitle',
defaultMessage: 'Course team',
},
headingSubtitle: {
id: 'course-authoring.course-team.subTitle',
defaultMessage: 'Settings',
},
addNewMemberButton: {
id: 'course-authoring.course-team.button.new-team-member',
defaultMessage: 'New team member',
},
});
export default messages;

View File

@@ -1,53 +0,0 @@
import { MODAL_TYPES } from './constants';
import messages from './info-modal/messages';
/**
* Create an info modal settings dependent on modal type
* @param {typeof MODAL_TYPES} modalType - one of MODAL_TYPES
* @param {string} currentEmail - email in current user
* @param {string} errorEmail - email from wrong request
* @param {string} courseName - current course name
* @returns {{
* title: string,
* message: string,
* variant: string,
* closeButtonText: string,
* submitButtonText: string,
* closeButtonVariant: string
* }}
*/
const getInfoModalSettings = (modalType, currentEmail, errorMessage, courseName, intl) => {
switch (modalType) {
case MODAL_TYPES.delete:
return {
title: intl.formatMessage(messages.deleteModalTitle),
message: intl.formatMessage(messages.deleteModalMessage, { email: currentEmail, courseName }),
variant: 'danger',
closeButtonText: intl.formatMessage(messages.deleteModalCancelButton),
submitButtonText: intl.formatMessage(messages.deleteModalDeleteButton),
closeButtonVariant: 'tertiary',
};
case MODAL_TYPES.error:
return {
title: intl.formatMessage(messages.errorModalTitle),
message: errorMessage,
variant: 'danger',
closeButtonText: intl.formatMessage(messages.errorModalOkButton),
closeButtonVariant: 'danger',
};
case MODAL_TYPES.warning:
return {
title: intl.formatMessage(messages.warningModalTitle),
message: intl.formatMessage(messages.warningModalMessage, { email: currentEmail, courseName }),
variant: 'warning',
closeButtonText: intl.formatMessage(messages.warningModalReturnButton),
mainButtonVariant: 'primary',
};
default:
return '';
}
};
// eslint-disable-next-line import/prefer-default-export
export { getInfoModalSettings };

View File

@@ -1,163 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
Container,
Layout,
} from '@edx/paragon';
import { Add as AddIcon } from '@edx/paragon/icons';
import { useSelector } from 'react-redux';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
import ProcessingNotification from '../generic/processing-notification';
import SubHeader from '../generic/sub-header/SubHeader';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import { RequestStatus } from '../data/constants';
import CourseHandouts from './course-handouts/CourseHandouts';
import CourseUpdate from './course-update/CourseUpdate';
import DeleteModal from './delete-modal/DeleteModal';
import UpdateForm from './update-form/UpdateForm';
import { REQUEST_TYPES } from './constants';
import messages from './messages';
import { useCourseUpdates } from './hooks';
import { getLoadingStatuses, getSavingStatuses } from './data/selectors';
import { matchesAnyStatus } from './utils';
const CourseUpdates = ({ courseId }) => {
const intl = useIntl();
const {
requestType,
courseUpdates,
courseHandouts,
courseUpdatesInitialValues,
isMainFormOpen,
isInnerFormOpen,
isUpdateFormOpen,
isDeleteModalOpen,
closeUpdateForm,
closeDeleteModal,
handleUpdatesSubmit,
handleOpenUpdateForm,
handleOpenDeleteForm,
handleDeleteUpdateSubmit,
} = useCourseUpdates({ courseId });
const {
isShow: isShowProcessingNotification,
title: processingNotificationTitle,
} = useSelector(getProcessingNotification);
const loadingStatuses = useSelector(getLoadingStatuses);
const savingStatuses = useSelector(getSavingStatuses);
const anyStatusFailed = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.FAILED);
const anyStatusInProgress = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.IN_PROGRESS);
const anyStatusPending = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.PENDING);
return (
<>
<Container size="xl" className="m-4">
<section className="setting-items mb-4 mt-5">
<Layout
lg={[{ span: 12 }]}
md={[{ span: 12 }]}
sm={[{ span: 12 }]}
xs={[{ span: 12 }]}
xl={[{ span: 12 }]}
>
<Layout.Element>
<article>
<div>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
instruction={intl.formatMessage(messages.sectionInfo)}
headerActions={(
<Button
variant="outline-primary"
iconBefore={AddIcon}
size="sm"
onClick={() => handleOpenUpdateForm(REQUEST_TYPES.add_new_update)}
disabled={isUpdateFormOpen}
>
{intl.formatMessage(messages.newUpdateButton)}
</Button>
)}
/>
<section className="updates-section">
{isMainFormOpen && (
<UpdateForm
isOpen={isUpdateFormOpen}
close={closeUpdateForm}
requestType={requestType}
onSubmit={handleUpdatesSubmit}
courseUpdatesInitialValues={courseUpdatesInitialValues}
/>
)}
<div className="updates-container">
<div className="p-4.5">
{courseUpdates.length ? courseUpdates.map((courseUpdate, index) => (
isInnerFormOpen(courseUpdate.id) ? (
<UpdateForm
isOpen={isUpdateFormOpen}
close={closeUpdateForm}
requestType={requestType}
isInnerForm
isFirstUpdate={index === 0}
onSubmit={handleUpdatesSubmit}
courseUpdatesInitialValues={courseUpdatesInitialValues}
/>
) : (
<CourseUpdate
dateForUpdate={courseUpdate.date}
contentForUpdate={courseUpdate.content}
onEdit={() => handleOpenUpdateForm(REQUEST_TYPES.edit_update, courseUpdate)}
onDelete={() => handleOpenDeleteForm(courseUpdate)}
isDisabledButtons={isUpdateFormOpen}
/>
))) : null}
</div>
<div className="updates-handouts-container">
<CourseHandouts
contentForHandouts={courseHandouts?.data || ''}
onEdit={() => handleOpenUpdateForm(REQUEST_TYPES.edit_handouts)}
isDisabledButtons={isUpdateFormOpen}
/>
</div>
<DeleteModal
isOpen={isDeleteModalOpen}
close={closeDeleteModal}
onDeleteSubmit={handleDeleteUpdateSubmit}
/>
{isShowProcessingNotification && (
<ProcessingNotification
isShow={isShowProcessingNotification}
title={processingNotificationTitle}
/>
)}
</div>
</section>
</div>
</article>
</Layout.Element>
</Layout>
</section>
</Container>
<div className="alert-toast">
<InternetConnectionAlert
isFailed={anyStatusFailed}
isQueryPending={anyStatusInProgress || anyStatusPending}
onInternetConnectionFailed={() => null}
/>
</div>
</>
);
};
CourseUpdates.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CourseUpdates;

View File

@@ -1,20 +0,0 @@
@import "./course-handouts/CourseHandouts";
@import "./course-update/CourseUpdate";
@import "./update-form/UpdateForm";
.updates-container {
@include pgn-box-shadow(1, "centered");
display: grid;
grid-template-columns: 65% 35%;
border: .0625rem solid $gray-200;
border-radius: .375rem;
background: $white;
overflow: hidden;
}
.updates-handouts-container {
border-left: .0625rem solid $gray-200;
padding: 1.875rem;
background: $white;
}

View File

@@ -1,220 +0,0 @@
import React from 'react';
import { render, waitFor, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
getCourseUpdatesApiUrl,
getCourseHandoutApiUrl,
updateCourseUpdatesApiUrl,
} from './data/api';
import {
createCourseUpdateQuery,
deleteCourseUpdateQuery,
editCourseHandoutsQuery,
editCourseUpdateQuery,
} from './data/thunk';
import initializeStore from '../store';
import { executeThunk } from '../utils';
import { courseUpdatesMock, courseHandoutsMock } from './__mocks__';
import CourseUpdates from './CourseUpdates';
import messages from './messages';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
jest.mock('@tinymce/tinymce-react', () => {
const originalModule = jest.requireActual('@tinymce/tinymce-react');
return {
__esModule: true,
...originalModule,
Editor: () => 'foo bar',
};
});
jest.mock('@edx/frontend-lib-content-components', () => ({
TinyMceWidget: () => <div>Widget</div>,
prepareEditorRef: jest.fn(() => ({
refReady: true,
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
})),
}));
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseUpdates courseId={courseId} />
</IntlProvider>
</AppProvider>
);
describe('<CourseUpdates />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseUpdatesApiUrl(courseId))
.reply(200, courseUpdatesMock);
axiosMock
.onGet(getCourseHandoutApiUrl(courseId))
.reply(200, courseHandoutsMock);
});
it('render CourseUpdates component correctly', async () => {
const {
getByText, getAllByTestId, getByTestId, getByRole,
} = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sectionInfo.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeInTheDocument();
expect(getAllByTestId('course-update')).toHaveLength(3);
expect(getByTestId('course-handouts')).toBeInTheDocument();
});
});
it('should create course update', async () => {
const { getByText } = render(<RootWrapper />);
const data = {
content: '<p>Some text</p>',
date: 'August 29, 2023',
};
axiosMock
.onPost(getCourseUpdatesApiUrl(courseId))
.reply(200, data);
await executeThunk(createCourseUpdateQuery(courseId, data), store.dispatch);
expect(getByText('Some text')).toBeInTheDocument();
expect(getByText(data.date)).toBeInTheDocument();
});
it('should edit course update', async () => {
const { getByText, queryByText } = render(<RootWrapper />);
const data = {
id: courseUpdatesMock[0].id,
content: '<p>Some text</p>',
date: 'August 29, 2023',
};
axiosMock
.onPut(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
.reply(200, data);
await executeThunk(editCourseUpdateQuery(courseId, data), store.dispatch);
expect(getByText('Some text')).toBeInTheDocument();
expect(getByText(data.date)).toBeInTheDocument();
expect(queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument();
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
});
it('should delete course update', async () => {
const { queryByText } = render(<RootWrapper />);
axiosMock
.onDelete(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
.reply(200);
await executeThunk(deleteCourseUpdateQuery(courseId, courseUpdatesMock[0].id), store.dispatch);
expect(queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument();
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
});
it('should edit course handouts', async () => {
const { getByText, queryByText } = render(<RootWrapper />);
const data = {
...courseHandoutsMock,
data: '<p>Some handouts 1</p>',
};
axiosMock
.onPut(getCourseHandoutApiUrl(courseId))
.reply(200, data);
await executeThunk(editCourseHandoutsQuery(courseId, data), store.dispatch);
expect(getByText('Some handouts 1')).toBeInTheDocument();
expect(queryByText(courseHandoutsMock.data)).not.toBeInTheDocument();
});
it('Add new update form is visible after clicking "New update" button', async () => {
const { getByText, getByRole, getAllByRole } = render(<RootWrapper />);
await waitFor(() => {
const editButtons = getAllByRole('button', { name: 'Edit' });
const deleteButtons = getAllByRole('button', { name: 'Delete' });
const newUpdateButton = getByRole('button', { name: messages.newUpdateButton.defaultMessage });
fireEvent.click(newUpdateButton);
expect(newUpdateButton).toBeDisabled();
editButtons.forEach((button) => expect(button).toBeDisabled());
deleteButtons.forEach((button) => expect(button).toBeDisabled());
expect(getByText('Add new update')).toBeInTheDocument();
});
});
it('Edit handouts form is visible after clicking "Edit" button', async () => {
const {
getByText, getByRole, getByTestId, getAllByRole,
} = render(<RootWrapper />);
await waitFor(() => {
const editHandoutsButton = getByTestId('course-handouts-edit-button');
const editButtons = getAllByRole('button', { name: 'Edit' });
const deleteButtons = getAllByRole('button', { name: 'Delete' });
fireEvent.click(editHandoutsButton);
expect(editHandoutsButton).toBeDisabled();
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
editButtons.forEach((button) => expect(button).toBeDisabled());
deleteButtons.forEach((button) => expect(button).toBeDisabled());
expect(getByText('Edit handouts')).toBeInTheDocument();
});
});
it('Edit update form is visible after clicking "Edit" button', async () => {
const {
getByText, getByRole, getAllByTestId, getAllByRole, queryByText,
} = render(<RootWrapper />);
await waitFor(() => {
const editUpdateFirstButton = getAllByTestId('course-update-edit-button')[0];
const editButtons = getAllByRole('button', { name: 'Edit' });
const deleteButtons = getAllByRole('button', { name: 'Delete' });
fireEvent.click(editUpdateFirstButton);
expect(getByText('Edit update')).toBeInTheDocument();
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
editButtons.forEach((button) => expect(button).toBeDisabled());
deleteButtons.forEach((button) => expect(button).toBeDisabled());
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
});
});
});

View File

@@ -1,83 +0,0 @@
module.exports = {
id: 'block-v1:edX+DemoX+Demo_Course+type@course_info+block@handouts',
display_name: 'Text',
category: 'course_info',
has_children: false,
edited_on: 'Jul 12, 2023 at 17:52 UTC',
published: true,
published_on: 'Jul 12, 2023 at 17:52 UTC',
studio_url: null,
released_to_students: false,
release_date: null,
visibility_state: 'unscheduled',
has_explicit_staff_lock: false,
start: '2030-01-01T00:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: null,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
data: 'Some handouts',
metadata: {},
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
};

View File

@@ -1,5 +0,0 @@
module.exports = [
{ id: 1, date: 'July 11, 2023', content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.' },
{ id: 2, date: 'August 20, 2023', content: 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.' },
{ id: 3, date: 'January 30, 2023', content: 'But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects, dislikes, or avoids pleasure itself' },
];

View File

@@ -1,2 +0,0 @@
export { default as courseUpdatesMock } from './courseUpdates';
export { default as courseHandoutsMock } from './courseHandouts';

View File

@@ -1,7 +0,0 @@
// eslint-disable-next-line import/prefer-default-export
export const REQUEST_TYPES = {
add_new_update: 'add_new_update',
edit_update: 'edit_update',
edit_handouts: 'edit_handouts',
delete_update: 'delete_update',
};

View File

@@ -1,41 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const CourseHandouts = ({ contentForHandouts, onEdit, isDisabledButtons }) => {
const intl = useIntl();
return (
<div className="course-handouts" data-testid="course-handouts">
<div className="course-handouts-header">
<h2 className="course-handouts-header__title lead">{intl.formatMessage(messages.handoutsTitle)}</h2>
<Button
className="course-handouts-header__btn"
data-testid="course-handouts-edit-button"
variant="outline-primary"
size="sm"
onClick={onEdit}
disabled={isDisabledButtons}
>
{intl.formatMessage(messages.editButton)}
</Button>
</div>
<div
className="small"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: contentForHandouts || '' }}
/>
</div>
);
};
CourseHandouts.propTypes = {
contentForHandouts: PropTypes.string.isRequired,
onEdit: PropTypes.func.isRequired,
isDisabledButtons: PropTypes.bool.isRequired,
};
export default CourseHandouts;

View File

@@ -1,16 +0,0 @@
.course-handouts {
.course-handouts-header {
display: flex;
justify-content: space-between;
margin-bottom: $spacer;
.course-handouts-header__title {
font-weight: 300;
color: $gray-800;
}
.course-handouts-header__btn {
align-self: flex-start;
}
}
}

View File

@@ -1,45 +0,0 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import CourseHandouts from './CourseHandouts';
import messages from './messages';
const onEditMock = jest.fn();
const handoutsContentMock = 'Handouts Content';
const renderComponent = (props) => render(
<IntlProvider locale="en">
<CourseHandouts
onEdit={onEditMock}
contentForHandouts={handoutsContentMock}
isDisabledButtons={false}
{...props}
/>
</IntlProvider>,
);
describe('<CourseHandouts />', () => {
it('render CourseHandouts component correctly', () => {
const { getByText, getByRole } = renderComponent();
expect(getByText(messages.handoutsTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(handoutsContentMock)).toBeInTheDocument();
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeInTheDocument();
});
it('calls the onEdit function when the edit button is clicked', () => {
const { getByRole } = renderComponent();
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
fireEvent.click(editButton);
expect(onEditMock).toHaveBeenCalledTimes(1);
});
it('"Edit" button is disabled when isDisabledButtons is true', () => {
const { getByRole } = renderComponent({ isDisabledButtons: true });
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
expect(editButton).toBeDisabled();
});
});

View File

@@ -1,14 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
handoutsTitle: {
id: 'course-authoring.course-updates.handouts.title',
defaultMessage: 'Course handouts',
},
editButton: {
id: 'course-authoring.course-updates.actions.edit',
defaultMessage: 'Edit',
},
});
export default messages;

View File

@@ -1,64 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, Icon } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Error as ErrorIcon } from '@edx/paragon/icons/es5';
import { isDateForUpdateValid } from './utils';
import messages from './messages';
const CourseUpdate = ({
dateForUpdate,
contentForUpdate,
onEdit,
onDelete,
isDisabledButtons,
}) => {
const intl = useIntl();
return (
<div className="course-update" data-testid="course-update">
<div className="course-update-header">
<span className="course-update-header__date small font-weight-bold">{dateForUpdate}</span>
{!isDateForUpdateValid(dateForUpdate) && (
<div className="course-update-header__error">
<Icon src={ErrorIcon} alt={intl.formatMessage(messages.errorMessage)} />
<p className="message-error small m-0">{intl.formatMessage(messages.errorMessage)}</p>
</div>
)}
<div className="course-update-header__action">
<Button
variant="outline-primary"
size="sm"
onClick={onEdit}
disabled={isDisabledButtons}
data-testid="course-update-edit-button"
>
{intl.formatMessage(messages.editButton)}
</Button>
<Button variant="outline-primary" size="sm" onClick={onDelete} disabled={isDisabledButtons}>
{intl.formatMessage(messages.deleteButton)}
</Button>
</div>
</div>
{Boolean(contentForUpdate) && (
<div
className="small text-gray-800"
data-testid="course-update-content"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: contentForUpdate }}
/>
)}
</div>
);
};
CourseUpdate.propTypes = {
dateForUpdate: PropTypes.string.isRequired,
contentForUpdate: PropTypes.string.isRequired,
onEdit: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
isDisabledButtons: PropTypes.bool.isRequired,
};
export default CourseUpdate;

View File

@@ -1,36 +0,0 @@
.course-update {
&:not(:first-child) {
padding-top: 1.875rem;
margin-top: 1.875rem;
border-top: 1px solid $light-400;
}
.course-update-header {
display: flex;
align-items: center;
margin-bottom: 1.125rem;
gap: .5rem;
.course-update-header__date {
line-height: 1.875rem;
letter-spacing: 1px;
}
.course-update-header__error {
display: flex;
align-items: center;
gap: .25rem;
svg {
color: $warning-300;
}
}
.course-update-header__action {
display: flex;
width: auto;
margin-left: auto;
gap: .5rem;
}
}
}

View File

@@ -1,72 +0,0 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import CourseUpdate from './CourseUpdate';
import messages from './messages';
const onEditMock = jest.fn();
const onDeleteMock = jest.fn();
const dateForUpdateMock = 'May 1, 2023';
const contentForUpdateMock = 'Update Content';
const renderComponent = (props) => render(
<IntlProvider locale="en">
<CourseUpdate
dateForUpdate={dateForUpdateMock}
contentForUpdate={contentForUpdateMock}
onEdit={onEditMock}
onDelete={onDeleteMock}
isDisabledButtons={false}
{...props}
/>
</IntlProvider>,
);
describe('<CourseUpdate />', () => {
it('render CourseUpdate component correctly', () => {
const { getByText, getByRole } = renderComponent();
expect(getByText(dateForUpdateMock)).toBeInTheDocument();
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeInTheDocument();
});
it('render CourseUpdate component without content correctly', () => {
const { getByText, queryByTestId, getByRole } = renderComponent({ contentForUpdate: '' });
expect(getByText(dateForUpdateMock)).toBeInTheDocument();
expect(queryByTestId('course-update-content')).not.toBeInTheDocument();
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeInTheDocument();
});
it('render error message when dateForUpdate is inValid', () => {
const { getByText } = renderComponent({ dateForUpdate: 'Welcome' });
expect(getByText(messages.errorMessage.defaultMessage)).toBeInTheDocument();
});
it('calls the onEdit function when the "Edit" button is clicked', () => {
const { getByRole } = renderComponent();
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
fireEvent.click(editButton);
expect(onEditMock).toHaveBeenCalledTimes(1);
});
it('calls the onDelete function when the "Delete" button is clicked', () => {
const { getByRole } = renderComponent();
const deleteButton = getByRole('button', { name: messages.deleteButton.defaultMessage });
fireEvent.click(deleteButton);
expect(onDeleteMock).toHaveBeenCalledTimes(1);
});
it('"Edit" and "Delete" buttons is disabled when isDisabledButtons is true', () => {
const { getByRole } = renderComponent({ isDisabledButtons: true });
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeDisabled();
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeDisabled();
});
});

View File

@@ -1,18 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
editButton: {
id: 'course-authoring.course-updates.button.edit',
defaultMessage: 'Edit',
},
deleteButton: {
id: 'course-authoring.course-updates.button.delete',
defaultMessage: 'Delete',
},
errorMessage: {
id: 'course-authoring.course-updates.date-invalid',
defaultMessage: 'Action required: Enter a valid date.',
},
});
export default messages;

Some files were not shown because too many files have changed in this diff Show More