Compare commits

...

112 Commits

Author SHA1 Message Date
Adolfo R. Brandes
14f035435c fix: Fix data API URL handling
All configuration calls must handled asynchronously, otherwise they risk
failure in runtime configuration scenarios.
2023-11-21 16:57:44 -03:00
Stanislav
fa25150e3d fix: Missed favicon in Safari (#635)
Co-authored-by: Stanislav Lunyachek <lunyachek@MacBook-Pro-M1.local>
2023-11-01 16:38:39 -04:00
German
3fe35344f0 feat: update copy for xpert summary card (#619) 2023-10-03 17:18:04 -03:00
edx-transifex-bot
bbca5a29b7 chore(i18n): update translations (#616)
Co-authored-by: Jenkins <sre+jenkins@edx.org>
2023-10-02 15:44:34 -04:00
Kristin Aoki
2a6a816baf feat: update footer and header to use frontend-component version (#618) 2023-10-02 15:06:33 -04:00
Mashal Malik
73f7d5d5f5 refactor: add @openedx in renovate automate configuration (#617) 2023-10-02 10:16:08 -04:00
Kristin Aoki
0871ce345a fix: studio home load screen (#615) 2023-09-29 14:29:31 -04:00
Kristin Aoki
01ddac380f fix: studio home UI bugs (#611) 2023-09-28 18:36:51 -04:00
Kristin Aoki
4840666664 fix: export download link prefix (#614) 2023-09-28 12:14:25 -04:00
Kristin Aoki
21e4ece669 fix: bump frontend-lib-content-components (#613) 2023-09-27 12:58:01 -04:00
Kristin Aoki
887a628c23 fix: export and import UI bugs (#612) 2023-09-27 12:10:45 -04:00
Kyrylo Kholodenko
2ea876ae4f feat: implement import page (#587) 2023-09-25 12:07:08 -04:00
Kristin Aoki
c47c800cfa fix: advanced settings card alignment (#608) 2023-09-22 17:29:38 -04:00
Kristin Aoki
ef9633af35 fix: course updates UI bugs (#606)
* fix: change edit and delete buttons to icons

* fix: padding and button color

* fix: delete buttons and variant

* fix: date error icon color

* fix: page explanation text
2023-09-22 15:51:04 -04:00
Kristin Aoki
217b86e616 fix: missing header items (#607) 2023-09-22 14:56:14 -04:00
sundasnoreen12
37aabc4948 fix: update toggle state based on api response (#604)
* fix: update toggle state based on api response

* refactor: added statefulbutton instead of button

---------

Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
2023-09-22 16:58:49 +05:00
ruzniaievdm
e099243437 feat: create Studio Home Page MFE (#589) 2023-09-19 10:04:43 -04:00
Kristin Aoki
6f238bdbe0 fix: bump frontend-lib-content-components (#602) 2023-09-15 14:02:18 -04:00
Kristin Aoki
77dfd0296c fix: bump frontend-lib-content-components (#601) 2023-09-14 09:42:04 -04:00
Kyrylo Kholodenko
1888993113 feat: implement export page (#586) 2023-09-14 09:07:24 -04:00
Kristin Aoki
fb28693854 fix: bump frontend-lib-content-components (#600) 2023-09-12 10:45:31 -04:00
Kristin Aoki
7f8c6f2d61 feat: update header to be keyboard accessible (#597) 2023-09-12 10:19:29 -04:00
Kristin Aoki
15984473b4 fix: bump frontend-lib-content-components (#595) 2023-09-07 09:53:20 -04:00
ruzniaievdm
b03ecf1562 fix: reworked grading deadline (#584) 2023-09-07 09:18:08 -04:00
Kristin Aoki
fdc5916ada fix: course team UI bugs (#592) 2023-09-06 14:45:17 -04:00
Kristin Aoki
a54d351e9c fix: schedule and details UI bugs (#588) 2023-09-06 12:32:19 -04:00
Kristin Aoki
62cde57556 fix: grading page UI bugs (#591) 2023-09-06 11:20:12 -04:00
Kristin Aoki
2bd8037d7b feat: change head title depending on page (#582) 2023-09-06 11:02:16 -04:00
Kyrylo Kholodenko
a1793efcc0 feat: add help-urls (#585) 2023-09-05 14:17:39 -04:00
Kristin Aoki
ed2eed5110 feat: add file zip on download (#580) 2023-09-05 10:23:47 -04:00
Kristin Aoki
e50b8c7407 feat: add file size and usage metrics (#573) 2023-08-31 12:21:37 -04:00
vladislavkeblysh
ffae3bd868 feat: Created Course updates page (#581) 2023-08-31 10:56:45 -04:00
Kristin Aoki
181f9c7a5f feat: add sort function and modal (#577)
* feat: add sort modal and function

* fix: dateAdded typo

* chore: update mock api data
2023-08-25 10:08:15 -04:00
Jhon Vente
1d95af5a31 [DOCS] Readme updated according OEP-55 (#526) 2023-08-24 09:16:27 -04:00
Kristin Aoki
d7a4b5b45b fix: add word break style for long words (#574) 2023-08-23 15:17:26 -04:00
vladislavkeblysh
2e8eed7504 feat: Created Course Team (#564) 2023-08-23 09:21:43 -04:00
German
d768bfc97a fix: xpert unit sumamries settings ui fixes (#576)
1. https://jira.2u.com/browse/ACADEMIC-16289
2. https://jira.2u.com/browse/ACADEMIC-16422
2023-08-22 15:43:17 -03:00
Jesper Hodge
9c997ab845 fix: Pass correct prop to TinyMceWidget and update FLCC (#575)
* fix: Pass correct prop to TinyMceWidget

* chore: update flcc

* fix: lockfile
2023-08-22 13:28:30 -04:00
Kristin Aoki
c1976ce4d3 feat: add delete confirmation modal (#570) 2023-08-21 17:28:49 -04:00
Kristin Aoki
be74de2b22 fix: file info bugs (#571) 2023-08-21 16:47:52 -04:00
German
fda1208660 feat: add xpert summaries configuration by default for units (#567)
* feat: add xpert summaries configuration by default for units
2023-08-21 16:14:39 -03:00
Kristin Aoki
b65f4f2b74 feat: bump frontend-lib-content-components (#569) 2023-08-17 11:26:39 -04:00
David Nuon
530c355787 fix: Add enabled badge to xpert settings tile (#566)
* feat: Add "Enabled" badge to xpert settings tile

* fix: Update model with state instead of non-existent prop from response
2023-08-15 09:17:58 -07:00
sundasnoreen12
fc21e22afb test: added test cases of discussion restriction (#556)
* test: added test cases of discussion restriction

* refactor: added null default value for dataTestId

---------

Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
2023-08-15 14:38:45 +05:00
Peter Kulko
f9bc5c4927 feat: created Grading page (#557) 2023-08-14 14:44:01 -04:00
Kristin Aoki
484b141328 fix: overflow-y scroll behavior (#565) 2023-08-14 12:10:53 -04:00
Kristin Aoki
dc0762312e feat: bump frontend-lib-content-components (#562) 2023-08-11 14:29:57 -04:00
Raymond Zhou
33f46be993 feat: flcc to 1.168.0 (#561) 2023-08-11 13:05:02 -04:00
Kristin Aoki
d1c176cfc8 fix: width and height of asset preview (#558) 2023-08-10 16:54:51 -04:00
David Nuon
17d14968fa fix: Change wording to not crowd xpert tile in preferences page (#560) 2023-08-09 12:40:14 -07:00
Kristin Aoki
df51130fce feat: bump frontend-lib-content-components (#559) 2023-08-09 15:22:41 -04:00
Kristin Aoki
bc05d2c01e feat: upgrade frontend-lib-content-components (#554) 2023-08-08 13:03:08 -04:00
ruzniaievdm
a0e37c0357 feat: Added Schedule and Details MFE page (#547) 2023-08-08 09:49:53 -04:00
sundasnoreen12
a218e7e5f8 test: added test cases for hide discussion tab (#552)
Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
2023-08-08 14:11:41 +05:00
David Nuon
f2a4386892 Update verbiage for Xpert Settings (#550)
* chore: Update verbiage for Xpert configuration screen

* fix: Change "generate" to "display" in xpert modal text

* fix: Updated learn more link

* fix: Change link and add targets
2023-08-07 07:17:40 -07:00
Kristin Aoki
c9b111a022 fix: remove env variable for files and uploads (#549) 2023-08-04 14:25:05 -04:00
Kristin Aoki
b9feb50a2c feat: add files and uploads page (#541) 2023-08-04 11:57:44 -04:00
Zachary Hancock
7fdf8da8ed fix: load up-to-date config on studio fetch (#548) 2023-08-01 15:37:13 -04:00
David Nuon
1dba6208a5 feat: configuration for xpert unit summaries (#540)
Adds setting modal for Xpert unit summaries

Includes hiding the config section for xpert summary - 
this is done based on a flag from 3d113d267c
2023-08-01 09:07:08 -04:00
Kristin Aoki
9f4422d1b9 fix: ui bugs (#542) 2023-07-31 17:23:07 -04:00
Omar Al-Ithawi
8bfc3f2945 feat: include paragon in atlas pull (#538)
This pull request is part of the [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) which is sparked by the [Translation Infrastructure update OEP-58](https://open-edx-proposals.readthedocs.io/en/latest/architectural-decisions/oep-0058-arch-translations-management.html#specification).
2023-07-25 11:16:29 -04:00
Kristin Aoki
0e1a7e2603 feat: make placeholder depend on api response (#537) 2023-07-25 10:32:31 -04:00
sundasnoreen12
cc7fc6a9e1 chore: add paragon messages (#530) (#534)
Co-authored-by: Mashal Malik <107556986+Mashal-m@users.noreply.github.com>
2023-07-24 19:13:20 +05:00
Mashal Malik
da1e7a0277 chore: add paragon messages (#530) 2023-07-21 11:08:39 +05:00
Peter Kulko
87ead24e20 feat: added Advanced settings page (#521)
Co-authored-by: sendr <sendr84@gmail.com>
Co-authored-by: ruzniaievdm <ruzniaievdm@gmail.com>
2023-07-19 10:45:50 -04:00
Kristin Aoki
e05e6325c9 fix: marketing base url typo (#533) 2023-07-17 11:35:39 -04:00
Leangseu Kim
b090c8c153 feat: add open responses card to page and resources 2023-07-13 11:07:58 -04:00
Kristin Aoki
3c3dfeb325 feat: use new studio footer (#532) 2023-07-11 13:05:10 -04:00
kenclary
7ee8cc7fb1 feat: update flcc to 1.62.0 (#528) 2023-06-30 15:41:54 -04:00
Raymond Zhou
912fff9b0f feat: update flcc to 1.61.0 (#527) 2023-06-30 11:59:46 -04:00
Kristin Aoki
2c71385ce7 fix: env custom pages conditional render (#525) 2023-06-29 10:45:56 -04:00
Kristin Aoki
139457087b feat: add custom pages (#510) 2023-06-27 16:26:35 -04:00
Raymond Zhou
3a26285bd1 feat: update flcc to 1.60.0 (#523) 2023-06-27 13:19:26 -04:00
Raymond Zhou
e2c1deaeb3 feat: flcc to 1.157.0 (#520) 2023-06-14 14:07:42 -04:00
Kristin Aoki
61baf1a886 chore: bump frontend-lib-content-components (#518) 2023-06-13 16:51:29 -04:00
Maria Grimaldi
51e5e7126c fix: cast progress graph configuration to string (#495) 2023-06-12 11:10:59 -04:00
Jenkins
a53a93ccee chore(i18n): update translations 2023-06-11 17:32:51 -04:00
Dmytro
e980f1f20e fix: disable invalid link Video Uploads (#511) 2023-06-08 11:06:11 -04:00
Kristin Aoki
fac9eab496 feat: bump frontend-lib-content-components (#509) 2023-06-06 11:38:57 -04:00
ayesha waris
1b1afcf195 feat: integrated backend discussions restriction with UI (#507)
* feat: integrated backend discussions restriction with UI

* refactor: code refactoring

* test: fixes test cases

* refactor: discussion restriction component

---------

Co-authored-by: ayesha waris <73840786+ayeshoali@users.noreply.github.com>
Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
2023-06-06 14:55:19 +05:00
Kristin Aoki
788f671626 feat: bump frontend-lib-content-components (#506) 2023-05-31 14:27:04 -04:00
Jenkins
ac7b4c9fcf chore(i18n): update translations 2023-05-28 17:32:49 -04:00
Mashal Malik
9a4af8ff2e feat: upgraded to node v18, added .nvmrc and updated workflows (#464)
* feat: upgraded to node v18, added .nvmrc and updated workflows

* feat: upfate validate workflow

* feat: update validate workflow

* fix: update lock file

* refactor: update validate file

* build: update pkg

* refactor: updated packages

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

* refactor: updated workflow

* refactor: updated workflow

* refactor: updated workflow

* build: update commit file

* build: update lock file

* refactor: update workflow

* refactor: update workflow

* refactor: update workflow

* refactor: update workflow

* build: update pkg

* build: update pkgs

* build: update lock file

---------

Co-authored-by: Bilal Qamar <59555732+BilalQamar95@users.noreply.github.com>
2023-05-25 13:49:24 +05:00
kenclary
9cfd8013d2 feat: update frontend-lib-content-components to v1.151.2 (#503) 2023-05-22 11:06:42 -04:00
Jenkins
74f5a0e8ee chore(i18n): update translations 2023-05-21 17:32:46 -04:00
sundasnoreen12
0d67c2588d feat: implemented discussion restriction UI (#494)
* feat: implemented discussion restriction UI

* refactor: fixed UI figma design issues

* refactor: fixed 2nd review points

* refactor: fixed review issues regarding confirmation popup

* refactor: changed tab component to button group

* perf: performance improvement changes

* refactor: fixed memorization issues

* refactor: fixed memo issues

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
2023-05-19 17:16:48 +05:00
Kristin Aoki
738f501cf9 feat: add new header and page routes (#501) 2023-05-18 17:08:38 -04:00
Chris Chávez
ff6a5d99d6 [FAL-3383] Implement new video UX flow on new video editor (#498)
* feat: Video Gallery URL updated to match to the new flow needs

* chore: Video Gallery Url updated and blockId added
2023-05-18 09:55:33 -04:00
Raymond Zhou
a46a34412c feat: update flcc to 1.150.1 (#500) 2023-05-17 15:43:48 -04:00
Kristin Aoki
db6c3172de feat: bump frontend-lib-content-components (#499) 2023-05-11 10:31:23 -04:00
Omar Al-Ithawi
0d38279950 feat: use atlas in make pull_translations (#490)
Changes
-------
 - Bump frontend-platform to bring `intl-imports.js` script
 - Move all i18n imports into `src/i18n/index.js` so `intl-imports.js` can
   override it with latest translations
 - Add `atlas` into `make pull_translations` when `OPENEDX_ATLAS_PULL`
   environment variable is set.

Refs: [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) implementing Translation Infrastructure OEP-58.
2023-05-09 10:13:50 -04:00
Kristin Aoki
3dd28082ea chore: bump frontend-lib-content-components (#497) 2023-05-05 11:01:36 -04:00
Kristin Aoki
767283cbc6 chore: bump frontend-lib-content-components (#496) 2023-05-03 10:56:28 -04:00
Jenkins
0066902127 chore(i18n): update translations 2023-04-30 17:32:45 -04:00
Raymond Zhou
9a567b875e feat: update flcc to 1.45.0 (#492) 2023-04-27 15:13:29 -04:00
Kristin Aoki
a7f877caf5 feat: bump flcc and paragon (#491) 2023-04-26 10:23:03 -04:00
Raymond Zhou
e75928a774 feat: update flcc to 1.142.0 (#489) 2023-04-25 16:31:58 -04:00
Chris Chávez
4b7f46852b [FAL-3375] Feat: Adding video selection gallery page to the routes (#461)
* feat: Adding video selection gallery page to the routes

* test: CourseAuthoringRoutes.test.jsx added
2023-04-25 12:45:00 -04:00
Pooja Kulkarni
1e0c128ad6 fix: make blockid parameter optional (#455) 2023-04-25 11:36:58 -04:00
Yoiber
e3887129fc chore(i18n): add more languages (#450)
* chore(i18n): add more languages

* chore(i18n): Pylint fixes

* chore(i18n): Typo to named the imports
2023-04-25 08:58:37 -04:00
ayesha waris
2eaf882734 fix: only global staff can see 2 edx discussion providers in settings (#477)
* fix: only global staff can see 2 edx discussion providers in settings

* test: adds and updated test cases for app list

* refactor: memoized showoneedxprovider constant

---------

Co-authored-by: ayesha waris <73840786+ayeshoali@users.noreply.github.com>
2023-04-25 15:00:35 +05:00
Jenkins
284c402a49 chore(i18n): update translations 2023-04-23 17:32:44 -04:00
connorhaugh
d08eb0e3a9 feat: upgrade flcc (#487) 2023-04-21 10:41:37 -04:00
Kristin Aoki
76b7623cb0 feat: bump frontend-lib-content-components 1.137.0 (#486) 2023-04-20 13:41:47 -04:00
connorhaugh
1e25091698 feat: add hotjar tracking (#485) 2023-04-19 16:56:18 -04:00
Jenkins
1289f7d4e2 chore(i18n): update translations 2023-04-16 17:27:42 -04:00
Kristin Aoki
eb1b2eb883 feat: bump frontend-lib-content-components 1.135.1 (#483) 2023-04-14 16:44:30 -04:00
Kristin Aoki
74e45139bf feat: bump frontend-lib-content-components 1.135.0 (#482) 2023-04-14 12:20:52 -04:00
Raymond Zhou
f9a240ade4 feat: update FLCC to 1.134.0 (#481) 2023-04-13 17:59:54 -04:00
Raymond Zhou
b09e7f3683 feat: update FLCC to ver1.133.0 (#480) 2023-04-13 13:12:20 -04:00
sundasnoreen12
b19d52555f refactor: removed all extra messages files (#475)
Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-04-13 17:13:49 +05:00
Raymond Zhou
ab4dd9a4a8 feat: update FLCC to 1.132.0 (#476) 2023-04-11 14:17:58 -04:00
570 changed files with 46714 additions and 29611 deletions

12
.env
View File

@@ -16,15 +16,27 @@ 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=''
SEGMENT_KEY=''
SITE_NAME=''
STUDIO_SHORT_NAME='Studio'
SUPPORT_EMAIL=''
SUPPORT_URL=''
USER_INFO_COOKIE_NAME=''
ENABLE_ACCESSIBILITY_PAGE=false
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
ENABLE_NEW_VIDEO_UPLOAD_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=''

View File

@@ -1,6 +1,6 @@
NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:2001'
BASE_URL='http://localhost:2001'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL=
@@ -16,17 +16,29 @@ LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGOUT_URL='http://localhost:18000/logout'
MARKETING_SITE_BASE_URL='http://localhost:18000'
TERMS_OF_SERVICE_URL=
PRIVACY_POLICY_URL=
ORDER_HISTORY_URL='localhost:1996/orders'
PORT=2001
PUBLISHER_BASE_URL=
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'
SITE_NAME='Your Plaform Name Here'
STUDIO_BASE_URL='http://localhost:18010'
SUPPORT_EMAIL='support@example.com'
STUDIO_SHORT_NAME='Studio'
SUPPORT_EMAIL=
SUPPORT_URL='https://support.edx.org'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_ACCESSIBILITY_PAGE=false
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
ENABLE_NEW_VIDEO_UPLOAD_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"

View File

@@ -1,5 +1,5 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:2001'
BASE_URL='http://localhost:2001'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
@@ -22,10 +22,16 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'
STUDIO_BASE_URL='http://localhost:18010'
STUDIO_SHORT_NAME='Studio'
SUPPORT_EMAIL='support@example.com'
SUPPORT_URL='https://support.edx.org'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_NEW_COURSE_OUTLINE_PAGE = true
ENABLE_NEW_VIDEO_UPLOAD_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"

View File

@@ -2,7 +2,7 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig(
'eslint',
'eslint',
{
rules: {
'jsx-a11y/label-has-associated-control': [2, {
@@ -10,7 +10,7 @@ module.exports = createConfig(
}],
'template-curly-spacing': 'off',
'react-hooks/exhaustive-deps': 'off',
indent: 'off',
indent: ['error', 2],
'no-restricted-exports': 'off',
},
},

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master

View File

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

2
.nvmrc
View File

@@ -1 +1 @@
v16
18

34
.stylelintrc.json Normal file
View File

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

View File

@@ -1,7 +1,8 @@
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"
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA,it_IT,pt_PT,de_DE"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
@@ -43,9 +44,23 @@ push_translations:
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
else
# Pulls translations using atlas.
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring
$(intl_imports) paragon frontend-component-footer frontend-app-course-authoring
endif
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -1,19 +1,65 @@
|Build Status| |Codecov| |license|
#############################
frontend-app-course-authoring
#############################
Please tag `@edx/teaching-and-learning <https://github.com/orgs/edx/teams/teaching-and-learning>`_ on any PRs or issues. Thanks.
|license-badge| |status-badge| |codecov-badge|
************
Introduction
************
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
===================
1. Clone the repo:
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
2. Use node v18.x.
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm use`_.
3. Install npm dependencies:
``cd frontend-app-course-authoring && npm install``
4. Start the dev server:
``npm start``
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
or whatever port you setup.
********
Features
********
@@ -151,22 +197,6 @@ Developing
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
Installation and Startup
========================
1. Clone the repo:
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
2. Install npm dependencies:
``cd frontend-app-course-authoring && npm install``
3. Start the dev server:
``npm start``
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
If your devstack includes the default Demo course, you can visit the following URLs to see content:
@@ -197,3 +227,87 @@ The production build is created with ``npm run build``.
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
:target: @edx/frontend-app-course-authoring
Internationalization
====================
Please see refer to the `frontend-platform i18n howto`_ for documentation on
internationalization.
.. _frontend-platform i18n howto: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
Getting Help
************
If you're having trouble, we have discussion forums at
https://discuss.openedx.org where you can connect with others in the community.
Our real-time conversations are on Slack. You can request a `Slack
invitation`_, then join our `community Slack workspace`_. Because this is a
frontend repository, the best place to discuss it would be in the `#wg-frontend
channel`_.
For anything non-trivial, the best path is to open an issue in this repository
with as many details about the issue you are facing as you can provide.
https://github.com/openedx/frontend-app-course-authoring/issues
For more information about these options, see the `Getting Help`_ page.
.. _Slack invitation: https://openedx.org/slack
.. _community Slack workspace: https://openedx.slack.com/
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
License
*******
The code in this repository is licensed under the AGPLv3 unless otherwise
noted.
Please see `LICENSE <LICENSE>`_ for details.
Contributing
************
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
to have a discussion about your new feature idea with the maintainers prior to
beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.
The Open edX Code of Conduct
****************************
All community members are expected to follow the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
People
******
The assigned maintainers for this component and other project details may be
found in `Backstage`_. Backstage pulls this data from the ``catalog-info.yaml``
file in this repo.
.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/frontend-app-course-authoring
Reporting Security Issues
*************************
Please do not report security issues in public, and email security@openedx.org instead.
.. |license-badge| image:: https://img.shields.io/github/license/openedx/frontend-app-course-authoring.svg
:target: https://github.com/openedx/frontend-app-course-authoring/blob/master/LICENSE
:alt: License
.. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen
.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-course-authoring/coverage.svg?branch=master
:target: https://codecov.io/github/openedx/frontend-app-course-authoring?branch=master
:alt: Codecov

View File

@@ -8,3 +8,5 @@ coverage:
default:
target: auto
threshold: 0%
ignore:
- "src/grading-settings/grading-scale/react-ranger.js"

32969
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,8 +12,9 @@
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
"stylelint": "stylelint \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests"
@@ -34,10 +35,12 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "11.1.1",
"@edx/frontend-lib-content-components": "^1.131.0",
"@edx/frontend-platform": "2.5.1",
"@edx/paragon": "^20.28.0",
"@edx/frontend-component-footer": "^12.3.0",
"@edx/frontend-component-header": "^4.7.0",
"@edx/frontend-enterprise-hotjar": "^1.2.1",
"@edx/frontend-lib-content-components": "^1.174.0",
"@edx/frontend-platform": "4.2.0",
"@edx/paragon": "^20.45.4",
"@fortawesome/fontawesome-svg-core": "1.2.28",
"@fortawesome/free-brands-svg-icons": "5.11.2",
"@fortawesome/free-regular-svg-icons": "5.11.2",
@@ -47,27 +50,33 @@
"classnames": "2.2.6",
"core-js": "3.8.1",
"email-validator": "2.0.4",
"file-saver": "^2.0.5",
"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-redux": "7.1.3",
"react-responsive": "8.1.0",
"react-router": "5.1.2",
"react-router-dom": "5.1.2",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-textarea-autosize": "^8.4.1",
"react-transition-group": "4.4.1",
"redux": "4.0.5",
"regenerator-runtime": "0.13.7",
"universal-cookie": "^4.0.4",
"uuid": "^3.4.0",
"yup": "0.31.1"
},
"devDependencies": {
"@edx/browserslist-config": "1.0.0",
"@edx/frontend-build": "12.3.0",
"@edx/frontend-build": "12.8.6",
"@edx/reactifex": "^1.0.3",
"@edx/stylelint-config-edx": "^2.3.0",
"@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.1",
"@testing-library/user-event": "^13.2.1",

View File

@@ -4,7 +4,7 @@
<title>Course Authoring | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%= process.env.FAVICON_URL %>" type="image/x-icon" />
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
</head>
<body>
<div id="root"></div>

View File

@@ -11,7 +11,7 @@
"rebaseStalePrs": true,
"packageRules": [
{
"matchPackagePatterns": ["@edx"],
"matchPackagePatterns": ["@edx", "@openedx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}

View File

@@ -1,16 +1,16 @@
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 Header from './studio-header/Header';
import { StudioFooter } from '@edx/frontend-component-footer';
import Header from './header';
import { fetchCourseDetail } from './data/thunks';
import { useModel } from './generic/model-store';
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
import { getCourseAppsApiStatus, getLoadingStatus } from './pages-and-resources/data/selectors';
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants';
import Loading from './generic/Loading';
@@ -37,12 +37,6 @@ AppHeader.defaultProps = {
courseOrg: null,
};
const AppFooter = () => (
<div className="mt-6">
<Footer />
</div>
);
const CourseAuthoringPage = ({ courseId, children }) => {
const dispatch = useDispatch();
@@ -56,31 +50,33 @@ const CourseAuthoringPage = ({ courseId, children }) => {
const courseOrg = courseDetail ? courseDetail.org : null;
const courseTitle = courseDetail ? courseDetail.name : courseId;
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
const inProgress = useSelector(getLoadingStatus) === RequestStatus.IN_PROGRESS;
const inProgress = useSelector(state => state.courseDetail.status) === RequestStatus.IN_PROGRESS;
const { pathname } = useLocation();
const showHeader = !pathname.includes('/editor');
if (courseAppsApiStatus === RequestStatus.DENIED) {
return (
<PermissionDeniedAlert />
);
}
return (
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
{/* While V2 Editors are tempoarily served from thier own pages
{/* While V2 Editors are temporarily served from their own pages
using url pattern containing /editor/,
we shouldn't have the header and footer on these pages.
This functionality will be removed in TNL-9591 */}
{inProgress ? !pathname.includes('/editor/') && <Loading />
: (
{inProgress ? showHeader && <Loading />
: (showHeader && (
<AppHeader
courseNumber={courseNumber}
courseOrg={courseOrg}
courseTitle={courseTitle}
courseId={courseId}
/>
)}
)
)}
{children}
{!inProgress && <AppFooter />}
{!inProgress && showHeader && <StudioFooter />}
</div>
);
};

View File

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

View File

@@ -2,10 +2,21 @@ 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';
import CourseExportPage from './export-page/CourseExportPage';
import CourseImportPage from './import-page/CourseImportPage';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -28,20 +39,73 @@ const CourseAuthoringRoutes = ({ courseId }) => {
return (
<CourseAuthoringPage courseId={courseId}>
<Switch>
<PageRoute path={`${path}/outline`}>
{process.env.ENABLE_NEW_COURSE_OUTLINE_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
<PageRoute path={`${path}/course_info`}>
<CourseUpdates courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/assets`}>
<FilesAndUploads courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/videos`}>
{process.env.ENABLE_NEW_VIDEO_UPLOAD_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
<PageRoute path={`${path}/pages-and-resources`}>
<PagesAndResources courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/proctored-exam-settings`}>
<ProctoredExamSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/editor/:blockType/:blockId`}>
<PageRoute path={`${path}/custom-pages`}>
<CustomPages courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/container/:blockId`}>
{process.env.ENABLE_UNIT_PAGE === 'true'
&& (
<Placeholder />
)}
</PageRoute>
<PageRoute path={`${path}/editor/course-videos/:blockId`}>
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
&& (
<EditorContainer
courseId={courseId}
/>
<VideoSelectorContainer
courseId={courseId}
/>
)}
</PageRoute>
<PageRoute path={`${path}/editor/:blockType/:blockId?`}>
{process.env.ENABLE_NEW_EDITOR_PAGES === 'true'
&& (
<EditorContainer
courseId={courseId}
/>
)}
</PageRoute>
<PageRoute path={`${path}/settings/details`}>
<ScheduleAndDetails courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/settings/grading`}>
<GradingSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/course_team`}>
<CourseTeam courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/settings/advanced`}>
<AdvancedSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/import`}>
<CourseImportPage courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/export`}>
<CourseExportPage courseId={courseId} />
</PageRoute>
</Switch>
</CourseAuthoringPage>
);

View File

@@ -0,0 +1,139 @@
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();
});
fit('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.getByText(pagesAndResourcesMockText)).toBeVisible();
expect(screen.queryByText(proctoredExamSeetingsMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
courseId,
}),
);
});
it('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

@@ -0,0 +1,285 @@
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 { useModel } from '../generic/model-store';
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 getPageHeadTitle from '../generic/utils';
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 courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
useEffect(() => {
dispatch(fetchCourseAppSettings(courseId));
dispatch(fetchProctoringExamErrors(courseId));
}, [courseId]);
const advancedSettingsData = useSelector(getCourseAppSettings);
const savingStatus = useSelector(getSavingStatus);
const proctoringExamErrors = useSelector(getProctoringExamErrors);
const settingsWithSendErrors = useSelector(getSendRequestErrors) || {};
const loadingSettingsStatus = useSelector(getLoadingStatus);
const isLoading = loadingSettingsStatus === RequestStatus.IN_PROGRESS;
const updateSettingsButtonState = {
labels: {
default: intl.formatMessage(messages.buttonSaveText),
pending: intl.formatMessage(messages.buttonSavingText),
},
disabledStates: ['pending'],
};
const {
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]);
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,118 @@
@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;
flex-grow: 1;
}
.pgn__card-header {
padding: 0 0 0 1.5rem;
}
.pgn__card-status {
padding: .625rem;
}
.pgn__card-header-content {
margin-top: 1.438rem;
margin-bottom: 1.438rem;
}
}
.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

@@ -0,0 +1 @@
$text-color-base: $gray-700;

View File

@@ -0,0 +1,140 @@
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 row m-0 align-items-center">
<Card.Header
className="col-6"
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.Spacer />
</ActionRow>
)}
/>
<Card.Section className="col-6 flex-grow-1">
<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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
about: {
id: 'course-authoring.advanced-settings.sidebar.about.title',
defaultMessage: 'What do advanced settings do?',
},
aboutDescription1: {
id: 'course-authoring.advanced-settings.sidebar.about.description-1',
defaultMessage: 'Advanced settings control specific course functionality. On this page, you can edit manual policies, which are JSON-based key and value pairs that control specific course settings.',
},
aboutDescription2: {
id: 'course-authoring.advanced-settings.sidebar.about.description-2',
defaultMessage: 'Any policies you modify here override all other information 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

36
src/constants.js Normal file
View File

@@ -0,0 +1,36 @@
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 = {
default: 'default',
pending: 'pending',
error: 'error',
};
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',
};
export const DEFAULT_TIME_STAMP = '00:00';
export const COURSE_CREATOR_STATES = {
unrequested: 'unrequested',
pending: 'pending',
granted: 'granted',
denied: 'denied',
disallowedForThisSite: 'disallowed_for_this_site',
};

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { history, initializeMockApp } from '@edx/frontend-platform';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import {
act, fireEvent, render, waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import { studioHomeMock } from '../studio-home/__mocks__';
import { getStudioHomeApiUrl } from '../studio-home/data/api';
import { RequestStatus } from '../data/constants';
import messages from './messages';
import CourseRerun from '.';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const RootWrapper = () => (
<MemoryRouter>
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseRerun intl={injectIntl} />
</IntlProvider>
</AppProvider>
</MemoryRouter>
);
describe('<CourseRerun />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
useSelector.mockReturnValue(studioHomeMock);
});
it('should render successfully', () => {
const { getByText, getAllByRole } = render(<RootWrapper />);
expect(getByText(messages.rerunTitle.defaultMessage));
expect(getAllByRole('button', { name: messages.cancelButton.defaultMessage }).length).toBe(2);
});
it('should navigate to /home on cancel button click', () => {
const { getAllByRole } = render(<RootWrapper />);
const cancelButton = getAllByRole('button', { name: messages.cancelButton.defaultMessage })[0];
fireEvent.click(cancelButton);
waitFor(() => {
expect(history.location.pathname).toBe('/home');
});
});
it('shows the spinner before the query is complete', async () => {
useSelector.mockReturnValue({ organizationLoadingStatus: RequestStatus.IN_PROGRESS });
await act(async () => {
const { getByRole } = render(<RootWrapper />);
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
});
it('should show footer', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText('Looking for help with Studio?')).toBeInTheDocument();
expect(getByText('LMS')).toHaveAttribute('href', process.env.LMS_BASE_URL);
});
});

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { render } from '@testing-library/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { studioHomeMock } from '../../studio-home/__mocks__';
import initializeStore from '../../store';
import CourseRerunForm from '.';
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));
let store;
const onClickCancelMock = jest.fn();
const RootWrapper = (props) => (
<IntlProvider locale="en">
<AppProvider store={store}>
<CourseRerunForm {...props} />
</AppProvider>
</IntlProvider>
);
const props = {
initialFormValues: {
displayName: '',
org: '',
number: '',
run: '',
},
onClickCancel: onClickCancelMock,
};
describe('<CourseRerunForm />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
useSelector.mockReturnValue(studioHomeMock);
});
it('renders description successfully', () => {
const { getByText } = render(<RootWrapper {...props} />);
expect(getByText('Provide identifying information for this re-run of the course. The original course is not affected in any way by a re-run', { exact: false })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { CreateOrRerunCourseForm } from '../../generic/create-or-rerun-course';
import messages from './messages';
const CourseRerunForm = ({ initialFormValues, onClickCancel }) => {
const intl = useIntl();
return (
<div className="mb-4.5">
<div className="my-2.5">{intl.formatMessage(messages.rerunCourseDescription, {
strong: (
<strong>{intl.formatMessage(messages.rerunCourseDescriptionStrong)}</strong>
),
})}
</div>
<CreateOrRerunCourseForm
initialValues={initialFormValues}
onClickCancel={onClickCancel}
/>
</div>
);
};
CourseRerunForm.propTypes = {
initialFormValues: PropTypes.shape({
displayName: PropTypes.string.isRequired,
org: PropTypes.string.isRequired,
number: PropTypes.string.isRequired,
run: PropTypes.string,
}).isRequired,
onClickCancel: PropTypes.func.isRequired,
};
export default CourseRerunForm;

View File

@@ -0,0 +1,14 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
rerunCourseDescription: {
id: 'course-authoring.course-rerun.form.description',
defaultMessage: 'Provide identifying information for this re-run of the course. The original course is not affected in any way by a re-run. {strong}',
},
rerunCourseDescriptionStrong: {
id: 'course-authoring.course-rerun.form.description.strong',
defaultMessage: 'Note: Together, the organization, course number, and course run must uniquely identify this new course instance.',
},
});
export default messages;

View File

@@ -0,0 +1,54 @@
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 CourseRerunSideBar from '.';
import messages from './messages';
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const renderComponent = (props) => render(
<AppProvider store={store} messages={{}}>
<IntlProvider locale="en">
<CourseRerunSideBar courseId={courseId} {...props} />
</IntlProvider>
</AppProvider>,
);
describe('<CourseRerunSideBar />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('render CourseRerunSideBar successfully', () => {
const { getByText } = renderComponent();
expect(getByText(messages.sectionTitle1.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sectionDescription1.defaultMessage, { exact: false })).toBeInTheDocument();
expect(getByText(messages.sectionTitle2.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sectionDescription2.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sectionTitle3.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sectionDescription3.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sectionLink4.defaultMessage)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,83 @@
import React from 'react';
import { v4 as uuid } from 'uuid';
import { Hyperlink } from '@edx/paragon';
import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
import { useHelpUrls } from '../../help-urls/hooks';
import { HelpSidebar } from '../../generic/help-sidebar';
import messages from './messages';
const CourseRerunSideBar = () => {
const intl = useIntl();
const { default: learnMoreUrl } = useHelpUrls(['default']);
const defaultCourseDate = new Date(Date.UTC(2030, 0, 1, 0, 0));
const localizedCourseDate = (
<FormattedDate
value={defaultCourseDate}
year="numeric"
month="long"
day="2-digit"
hour="numeric"
minute="numeric"
/>
);
const sidebarMessages = [
{
title: intl.formatMessage(messages.sectionTitle1),
description: `${intl.formatMessage(messages.sectionDescription1)}`,
date: localizedCourseDate,
},
{
title: intl.formatMessage(messages.sectionTitle2),
description: intl.formatMessage(messages.sectionDescription2),
},
{
title: intl.formatMessage(messages.sectionTitle3),
description: intl.formatMessage(messages.sectionDescription3),
},
{
link: {
text: intl.formatMessage(messages.sectionLink4),
href: learnMoreUrl,
},
},
];
return (
<HelpSidebar
intl={intl}
showOtherSettings={false}
className="mt-3"
>
{sidebarMessages.map(({
title,
description,
link,
date,
}, index) => {
const isLastSection = index === sidebarMessages.length - 1;
return (
<div key={uuid()}>
<h4 className="help-sidebar-about-title">{title}</h4>
<p className="help-sidebar-about-descriptions">{description} {date}</p>
{!!link && (
<Hyperlink
className="small"
destination={link.href || ''}
target="_blank"
showLaunchIcon={false}
>
{link.text}
</Hyperlink>
)}
{!isLastSection && <hr className="my-3.5" />}
</div>
);
})}
</HelpSidebar>
);
};
export default CourseRerunSideBar;

View File

@@ -0,0 +1,34 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
sectionTitle1: {
id: 'course-authoring.course-rerun.sidebar.section-1.title',
defaultMessage: 'When will my course re-run start?',
},
sectionDescription1: {
id: 'course-authoring.course-rerun.sidebar.section-1.description',
defaultMessage: 'The new course is set to start on',
},
sectionTitle2: {
id: 'course-authoring.course-rerun.sidebar.section-2.title',
defaultMessage: 'What transfers from the original course?',
},
sectionDescription2: {
id: 'course-authoring.course-rerun.sidebar.section-2.description',
defaultMessage: 'The new course has the same course outline and content as the original course. All problems, videos, announcements, and other files are duplicated to the new course.',
},
sectionTitle3: {
id: 'course-authoring.course-rerun.sidebar.section-3.title',
defaultMessage: 'What does not transfer from the original course?',
},
sectionDescription3: {
id: 'course-authoring.course-rerun.sidebar.section-3.description',
defaultMessage: 'You are the only member of the new course\'s staff. No students are enrolled in the course, and there is no student data. There is no content in the discussion topics or wiki.',
},
sectionLink4: {
id: 'course-authoring.course-rerun.sidebar.section-4.link',
defaultMessage: 'Learn more about course re-runs',
},
});
export default messages;

View File

@@ -0,0 +1,67 @@
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { history } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { RequestStatus } from '../data/constants';
import { updateSavingStatus } from '../generic/data/slice';
import {
getSavingStatus,
getRedirectUrlObj,
getCourseRerunData,
getCourseData,
} from '../generic/data/selectors';
import { fetchCourseRerunQuery, fetchOrganizationsQuery } from '../generic/data/thunks';
import { fetchStudioHomeData } from '../studio-home/data/thunks';
const useCourseRerun = (courseId) => {
const intl = useIntl();
const dispatch = useDispatch();
const savingStatus = useSelector(getSavingStatus);
const courseData = useSelector(getCourseData);
const courseRerunData = useSelector(getCourseRerunData);
const redirectUrlObj = useSelector(getRedirectUrlObj);
const {
displayName = '',
org = '',
run = '',
number = '',
} = courseRerunData;
const originalCourseData = `${org} ${number} ${run}`;
const initialFormValues = {
displayName,
org,
number,
run: '',
};
useEffect(() => {
dispatch(fetchStudioHomeData());
dispatch(fetchCourseRerunQuery(courseId));
dispatch(fetchOrganizationsQuery());
}, []);
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
dispatch(updateSavingStatus({ status: '' }));
const { url } = redirectUrlObj;
if (url) {
history.push('/home');
}
}
}, [savingStatus]);
return {
intl,
courseData,
displayName,
savingStatus,
initialFormValues,
originalCourseData,
dispatch,
};
};
// eslint-disable-next-line import/prefer-default-export
export { useCourseRerun };

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import {
Container,
Layout,
Stack,
ActionRow,
Button,
} from '@edx/paragon';
import { history } from '@edx/frontend-platform';
import { StudioFooter } from '@edx/frontend-component-footer';
import Header from '../header';
import Loading from '../generic/Loading';
import { getLoadingStatuses } from '../generic/data/selectors';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import { RequestStatus } from '../data/constants';
import CourseRerunForm from './course-rerun-form';
import CourseRerunSideBar from './course-rerun-sidebar';
import messages from './messages';
import { useCourseRerun } from './hooks';
const CourseRerun = ({ courseId }) => {
const {
intl,
displayName,
savingStatus,
initialFormValues,
originalCourseData,
} = useCourseRerun(courseId);
const { organizationLoadingStatus } = useSelector(getLoadingStatuses);
if (organizationLoadingStatus === RequestStatus.IN_PROGRESS) {
return <Loading />;
}
const handleRerunCourseCancel = () => {
history.push('/home');
};
return (
<>
<Header isHiddenMainMenu />
<Container size="xl" className="small p-4 mt-3">
<section className="mb-4">
<article>
<section>
<header className="d-flex">
<Stack>
<h2>
{intl.formatMessage(messages.rerunTitle)} {displayName}
</h2>
<span className="large">{originalCourseData}</span>
</Stack>
<ActionRow className="ml-auto">
<Button variant="outline-primary" size="sm" onClick={handleRerunCourseCancel}>
{intl.formatMessage(messages.cancelButton)}
</Button>
</ActionRow>
</header>
<hr />
</section>
</article>
<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>
<CourseRerunForm
initialFormValues={initialFormValues}
onClickCancel={handleRerunCourseCancel}
/>
</Layout.Element>
<Layout.Element>
<CourseRerunSideBar />
</Layout.Element>
</Layout>
</section>
</Container>
<div className="alert-toast">
<InternetConnectionAlert
isFailed={savingStatus === RequestStatus.FAILED}
isQueryPending={savingStatus === RequestStatus.PENDING}
/>
</div>
<StudioFooter />
</>
);
};
CourseRerun.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CourseRerun;

View File

@@ -0,0 +1,14 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
rerunTitle: {
id: 'course-authoring.course-rerun.title',
defaultMessage: 'Create a re-run of',
},
cancelButton: {
id: 'course-authoring.course-rerun.actions.button.cancel',
defaultMessage: 'Cancel',
},
});
export default messages;

View File

@@ -0,0 +1,168 @@
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 { useModel } from '../generic/model-store';
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';
import getPageHeadTitle from '../generic/utils';
const CourseTeam = ({ courseId }) => {
const intl = useIntl();
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
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="px-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="primary"
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Add as IconAdd } from '@edx/paragon/icons';
import { Button } from '@edx/paragon';
import messages from './messages';
const AddTeamMember = ({ onFormOpen, isButtonDisable }) => {
const intl = useIntl();
return (
<div className="add-team-member bg-gray-100" data-testid="add-team-member">
<div className="add-team-member-info">
<h3 className="add-team-member-title font-weight-bold">{intl.formatMessage(messages.title)}</h3>
<span className="text-gray-500 small">{intl.formatMessage(messages.description)}</span>
</div>
<Button
variant="primary"
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
export const MODAL_TYPES = {
error: 'error',
delete: 'delete',
warning: 'warning',
};
export const BADGE_STATES = {
admin: 'primary-700',
staff: 'gray-500',
};
export const USER_ROLES = {
admin: 'instructor',
staff: 'staff',
};
export const EXAMPLE_USER_EMAIL = 'username@domain.com';

View File

@@ -0,0 +1,85 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Badge,
Button,
Icon,
IconButtonWithTooltip,
MailtoLink,
} from '@edx/paragon';
import { DeleteOutline } 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;
const badgeColor = isAdminRole ? BADGE_STATES.admin : BADGE_STATES.staff;
return (
<div className="course-team-member" data-testid="course-team-member">
<div className="member-info">
<Badge className={`badge-current-user bg-${badgeColor} text-light-100`}>
{isAdminRole
? intl.formatMessage(messages.roleAdmin)
: intl.formatMessage(messages.roleStaff)}
{currentUserEmail === email && (
<span className="badge-current-user x-small text-light-500">{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>
<IconButtonWithTooltip
src={DeleteOutline}
tooltipContent={intl.formatMessage(messages.deleteUserButton)}
onClick={() => onDelete(email)}
iconAs={Icon}
alt={intl.formatMessage(messages.deleteUserButton)}
data-testid="delete-button"
/>
</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

@@ -0,0 +1,52 @@
.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 {
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;
}
}

View File

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

View File

@@ -0,0 +1,34 @@
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',
},
deleteUserButton: {
id: 'course-authoring.course-team.member.button.delete',
defaultMessage: 'Delete user',
},
});
export default messages;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

139
src/course-team/hooks.jsx Normal file
View File

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

View File

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

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

View File

@@ -0,0 +1,42 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
deleteModalTitle: {
id: 'course-authoring.course-team.member.button.remove',
defaultMessage: 'Delete course team member',
},
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

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

53
src/course-team/utils.js Normal file
View File

@@ -0,0 +1,53 @@
import { MODAL_TYPES } from './constants';
import messages from './info-modal/messages';
/**
* Create an info modal settings dependent on modal type
* @param {typeof MODAL_TYPES} modalType - one of MODAL_TYPES
* @param {string} currentEmail - email in current user
* @param {string} errorEmail - email from wrong request
* @param {string} courseName - current course name
* @returns {{
* title: string,
* message: string,
* variant: string,
* closeButtonText: string,
* submitButtonText: string,
* closeButtonVariant: string
* }}
*/
const getInfoModalSettings = (modalType, currentEmail, errorMessage, courseName, intl) => {
switch (modalType) {
case MODAL_TYPES.delete:
return {
title: intl.formatMessage(messages.deleteModalTitle),
message: intl.formatMessage(messages.deleteModalMessage, { email: currentEmail, courseName }),
variant: '',
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: 'primary',
};
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

@@ -0,0 +1,168 @@
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 { useModel } from '../generic/model-store';
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';
import getPageHeadTitle from '../generic/utils';
const CourseUpdates = ({ courseId }) => {
const intl = useIntl();
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
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="px-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="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

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

View File

@@ -0,0 +1,224 @@
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, getAllByTestId } = render(<RootWrapper />);
await waitFor(() => {
const editUpdateButtons = getAllByTestId('course-update-edit-button');
const deleteButtons = getAllByTestId('course-update-delete-button');
const editHandoutsButtons = getAllByTestId('course-handouts-edit-button');
const newUpdateButton = getByRole('button', { name: messages.newUpdateButton.defaultMessage });
fireEvent.click(newUpdateButton);
expect(newUpdateButton).toBeDisabled();
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
editHandoutsButtons.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, getAllByTestId } = render(<RootWrapper />);
await waitFor(() => {
const editUpdateButtons = getAllByTestId('course-update-edit-button');
const deleteButtons = getAllByTestId('course-update-delete-button');
const editHandoutsButtons = getAllByTestId('course-handouts-edit-button');
const editHandoutsButton = editHandoutsButtons[0];
fireEvent.click(editHandoutsButton);
expect(editHandoutsButton).toBeDisabled();
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
editHandoutsButtons.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, queryByText,
} = render(<RootWrapper />);
await waitFor(() => {
const editUpdateButtons = getAllByTestId('course-update-edit-button');
const deleteButtons = getAllByTestId('course-update-delete-button');
const editHandoutsButtons = getAllByTestId('course-handouts-edit-button');
const editUpdateFirstButton = editUpdateButtons[0];
fireEvent.click(editUpdateFirstButton);
expect(getByText('Edit update')).toBeInTheDocument();
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
editHandoutsButtons.forEach((button) => expect(button).toBeDisabled());
deleteButtons.forEach((button) => expect(button).toBeDisabled());
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
});
});
});

View File

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

View File

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

View File

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

View File

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

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