Compare commits

...

30 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
257 changed files with 18402 additions and 7083 deletions

6
.env
View File

@@ -23,6 +23,7 @@ PUBLISHER_BASE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEGMENT_KEY=''
SITE_NAME=''
STUDIO_SHORT_NAME='Studio'
SUPPORT_EMAIL=''
SUPPORT_URL=''
USER_INFO_COOKIE_NAME=''
@@ -30,13 +31,8 @@ ENABLE_ACCESSIBILITY_PAGE=false
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_NEW_HOME_PAGE = false
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
ENABLE_NEW_GRADING_PAGE = false
ENABLE_NEW_COURSE_TEAM_PAGE = false
ENABLE_NEW_IMPORT_PAGE = false
ENABLE_NEW_EXPORT_PAGE = false
ENABLE_UNIT_PAGE = false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false
BBB_LEARN_MORE_URL=''

View File

@@ -25,6 +25,7 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='Your Plaform Name Here'
STUDIO_BASE_URL='http://localhost:18010'
STUDIO_SHORT_NAME='Studio'
SUPPORT_EMAIL=
SUPPORT_URL='https://support.edx.org'
USER_INFO_COOKIE_NAME='edx-user-info'
@@ -32,13 +33,8 @@ ENABLE_ACCESSIBILITY_PAGE=false
ENABLE_PROGRESS_GRAPH_SETTINGS=false
ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_NEW_HOME_PAGE = false
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
ENABLE_NEW_GRADING_PAGE = false
ENABLE_NEW_COURSE_TEAM_PAGE = false
ENABLE_NEW_IMPORT_PAGE = false
ENABLE_NEW_EXPORT_PAGE = false
ENABLE_UNIT_PAGE = false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false
BBB_LEARN_MORE_URL=''

View File

@@ -22,19 +22,15 @@ 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_HOME_PAGE = false
ENABLE_NEW_COURSE_OUTLINE_PAGE = true
ENABLE_NEW_VIDEO_UPLOAD_PAGE = true
ENABLE_NEW_GRADING_PAGE = true
ENABLE_NEW_COURSE_TEAM_PAGE = true
ENABLE_NEW_IMPORT_PAGE = true
ENABLE_NEW_EXPORT_PAGE = true
ENABLE_UNIT_PAGE = true
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = true
BBB_LEARN_MORE_URL=''

View File

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

1100
package-lock.json generated
View File

@@ -10,9 +10,10 @@
"license": "AGPL-3.0",
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "12.0.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.169.3",
"@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",
@@ -24,6 +25,7 @@
"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",
@@ -33,7 +35,6 @@
"react-datepicker": "^4.13.0",
"react-dom": "16.14.0",
"react-helmet": "^6.1.0",
"react-ranger": "^2.1.0",
"react-redux": "7.1.3",
"react-responsive": "8.1.0",
"react-router": "5.2.0",
@@ -42,6 +43,7 @@
"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"
},
@@ -2200,69 +2202,142 @@
}
},
"node_modules/@edx/frontend-component-footer": {
"version": "12.0.0",
"license": "AGPL-3.0",
"version": "12.3.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-12.3.0.tgz",
"integrity": "sha512-ivCtioyP4SceYM4/ugVtif4c41Y+epA0NM7sSB/x6s9A/RTQXb2TY3fDc9lB3ah/0+pRwGVJJEVYkPAZ4JdC/g==",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.4.0",
"@fortawesome/free-brands-svg-icons": "6.4.0",
"@fortawesome/free-regular-svg-icons": "6.4.0",
"@fortawesome/free-solid-svg-icons": "6.4.0",
"@fortawesome/react-fontawesome": "0.2.0"
"@edx/paragon": "^21.3.1",
"@fortawesome/fontawesome-svg-core": "6.4.2",
"@fortawesome/free-brands-svg-icons": "6.4.2",
"@fortawesome/free-regular-svg-icons": "6.4.2",
"@fortawesome/free-solid-svg-icons": "6.4.2",
"@fortawesome/react-fontawesome": "0.2.0",
"lodash": "^4.17.21"
},
"peerDependencies": {
"@edx/frontend-platform": "^4.0.0",
"@edx/frontend-platform": "^4.0.0 || ^5.0.0",
"prop-types": "^15.5.10",
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@edx/paragon": {
"version": "21.3.1",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-21.3.1.tgz",
"integrity": "sha512-bXTUaOEmT8XLnDQzYS8QLMvWK5K2BN4jHlx25lO8N0XWRQeDiQTdbx8OrEbv8QOPTlrv0an5MZc+qjlleJFObg==",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/react-fontawesome": "^0.1.18",
"@popperjs/core": "^2.11.4",
"bootstrap": "^4.6.2",
"chalk": "^4.1.2",
"child_process": "^1.0.2",
"classnames": "^2.3.1",
"email-prop-type": "^3.0.0",
"file-selector": "^0.6.0",
"font-awesome": "^4.7.0",
"glob": "^8.0.3",
"inquirer": "^8.2.5",
"lodash.uniqby": "^4.7.0",
"mailto-link": "^2.0.0",
"prop-types": "^15.8.1",
"react-bootstrap": "^1.6.5",
"react-colorful": "^5.6.1",
"react-dropzone": "^14.2.1",
"react-focus-on": "^3.5.4",
"react-loading-skeleton": "^3.1.0",
"react-popper": "^2.2.5",
"react-proptype-conditional-require": "^1.0.4",
"react-responsive": "^8.2.0",
"react-table": "^7.7.0",
"react-transition-group": "^4.4.2",
"tabbable": "^5.3.3",
"uncontrollable": "^7.2.1",
"uuid": "^9.0.0"
},
"bin": {
"paragon": "bin/paragon-scripts.js"
},
"peerDependencies": {
"react": "^16.8.6 || ^17.0.0",
"react-dom": "^16.8.6 || ^17.0.0",
"react-intl": "^5.25.1 || ^6.4.0"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@edx/paragon/node_modules/@fortawesome/react-fontawesome": {
"version": "0.1.19",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.19.tgz",
"integrity": "sha512-Hyb+lB8T18cvLNX0S3llz7PcSOAJMLwiVKBuuzwM/nI5uoBw+gQjnf9il0fR1C3DKOI5Kc79pkJ4/xB0Uw9aFQ==",
"dependencies": {
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
"react": ">=16.x"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@edx/paragon/node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz",
"integrity": "sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA==",
"hasInstallScript": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.4.0",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.2.tgz",
"integrity": "sha512-gjYDSKv3TrM2sLTOKBc5rH9ckje8Wrwgx1CxAPbN5N3Fm4prfi7NsJVWd1jklp7i5uSCVwhZS5qlhMXqLrpAIg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.0"
"@fortawesome/fontawesome-common-types": "6.4.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.4.0",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.2.tgz",
"integrity": "sha512-LKOwJX0I7+mR/cvvf6qIiqcERbdnY+24zgpUSouySml+5w8B4BJOx8EhDR/FTKAu06W12fmUIcv6lzPSwYKGGg==",
"hasInstallScript": true,
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.0"
"@fortawesome/fontawesome-common-types": "6.4.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.4.0",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.2.tgz",
"integrity": "sha512-0+sIUWnkgTVVXVAPQmW4vxb9ZTHv0WstOa3rBx9iPxrrrDH6bNLsDYuwXF9b6fGm+iR7DKQvQshUH/FJm3ed9Q==",
"hasInstallScript": true,
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.0"
"@fortawesome/fontawesome-common-types": "6.4.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.4.0",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.2.tgz",
"integrity": "sha512-sYwXurXUEQS32fZz9hVCUUv/xu49PEJEyUOsA51l6PU/qVgfbTb2glsTEaJngVVT8VqBATRIdh7XVgV1JF1LkA==",
"hasInstallScript": true,
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.0"
"@fortawesome/fontawesome-common-types": "6.4.2"
},
"engines": {
"node": ">=6"
@@ -2288,6 +2363,485 @@
"react-is": "^16.13.1"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/classnames": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
},
"node_modules/@edx/frontend-component-footer/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/@edx/frontend-component-footer/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/react-responsive": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.2.0.tgz",
"integrity": "sha512-iagCqVrw4QSjhxKp3I/YK6+ODkWY6G+YPElvdYKiUUbywwh9Ds0M7r26Fj2/7dWFFbOpcGnJE6uE7aMck8j5Qg==",
"dependencies": {
"hyphenate-style-name": "^1.0.0",
"matchmediaquery": "^0.3.0",
"prop-types": "^15.6.1",
"shallow-equal": "^1.1.0"
},
"engines": {
"node": ">= 0.10"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@edx/frontend-component-header": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-4.7.0.tgz",
"integrity": "sha512-hJCWfdEed8h7aRKmo5lCMyemtMR272q/g1WOetMy8C8ZzeNvf8t94WPFCK4OlbHWTQBd5UiaqGWQWmaGXm0jVg==",
"dependencies": {
"@edx/paragon": "21.1.10",
"@fortawesome/fontawesome-svg-core": "6.4.2",
"@fortawesome/free-brands-svg-icons": "6.4.2",
"@fortawesome/free-regular-svg-icons": "6.4.2",
"@fortawesome/free-solid-svg-icons": "6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"axios-mock-adapter": "1.21.5",
"babel-polyfill": "6.26.0",
"react-responsive": "8.2.0",
"react-transition-group": "4.4.5"
},
"peerDependencies": {
"@edx/frontend-platform": "^4.0.0 || ^5.0.0",
"prop-types": "^15.5.10",
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@edx/paragon": {
"version": "21.1.10",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-21.1.10.tgz",
"integrity": "sha512-5U8tUaL20gDiKfEDr/tuRXrl7fJsN+KgAIn5bWkTtS5Us7r+H+m3LkD58HY7Ntwj8bCrSEtW7YuK3PMabXcMRA==",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/react-fontawesome": "^0.1.18",
"@popperjs/core": "^2.11.4",
"bootstrap": "^4.6.2",
"chalk": "^4.1.2",
"child_process": "^1.0.2",
"classnames": "^2.3.1",
"email-prop-type": "^3.0.0",
"file-selector": "^0.6.0",
"font-awesome": "^4.7.0",
"glob": "^8.0.3",
"inquirer": "^8.2.5",
"lodash.uniqby": "^4.7.0",
"mailto-link": "^2.0.0",
"prop-types": "^15.8.1",
"react-bootstrap": "^1.6.5",
"react-colorful": "^5.6.1",
"react-dropzone": "^14.2.1",
"react-focus-on": "^3.5.4",
"react-loading-skeleton": "^3.1.0",
"react-popper": "^2.2.5",
"react-proptype-conditional-require": "^1.0.4",
"react-responsive": "^8.2.0",
"react-table": "^7.7.0",
"react-transition-group": "^4.4.2",
"tabbable": "^5.3.3",
"uncontrollable": "^7.2.1",
"uuid": "^9.0.0"
},
"bin": {
"paragon": "bin/paragon-scripts.js"
},
"peerDependencies": {
"react": "^16.8.6 || ^17.0.0",
"react-dom": "^16.8.6 || ^17.0.0",
"react-intl": "^5.25.1 || ^6.4.0"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@edx/paragon/node_modules/@fortawesome/react-fontawesome": {
"version": "0.1.19",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.19.tgz",
"integrity": "sha512-Hyb+lB8T18cvLNX0S3llz7PcSOAJMLwiVKBuuzwM/nI5uoBw+gQjnf9il0fR1C3DKOI5Kc79pkJ4/xB0Uw9aFQ==",
"dependencies": {
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
"react": ">=16.x"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@edx/paragon/node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz",
"integrity": "sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.2.tgz",
"integrity": "sha512-gjYDSKv3TrM2sLTOKBc5rH9ckje8Wrwgx1CxAPbN5N3Fm4prfi7NsJVWd1jklp7i5uSCVwhZS5qlhMXqLrpAIg==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.2.tgz",
"integrity": "sha512-LKOwJX0I7+mR/cvvf6qIiqcERbdnY+24zgpUSouySml+5w8B4BJOx8EhDR/FTKAu06W12fmUIcv6lzPSwYKGGg==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.2.tgz",
"integrity": "sha512-0+sIUWnkgTVVXVAPQmW4vxb9ZTHv0WstOa3rBx9iPxrrrDH6bNLsDYuwXF9b6fGm+iR7DKQvQshUH/FJm3ed9Q==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.2.tgz",
"integrity": "sha512-sYwXurXUEQS32fZz9hVCUUv/xu49PEJEyUOsA51l6PU/qVgfbTb2glsTEaJngVVT8VqBATRIdh7XVgV1JF1LkA==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/react-fontawesome": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.0.tgz",
"integrity": "sha512-uHg75Rb/XORTtVt7OS9WoK8uM276Ufi7gCzshVWkUJbHhh3svsUUeqXerrM96Wm7fRiDzfKRwSoahhMIkGAYHw==",
"dependencies": {
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
"react": ">=16.3"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/react-fontawesome/node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/@edx/frontend-component-header/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@edx/frontend-component-header/node_modules/axios-mock-adapter": {
"version": "1.21.5",
"resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.21.5.tgz",
"integrity": "sha512-5NI1V/VK+8+JeTF8niqOowuysA4b8mGzdlMN/QnTnoXbYh4HZSNiopsDclN2g/m85+G++IrEtUdZaQ3GnaMsSA==",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"is-buffer": "^2.0.5"
},
"peerDependencies": {
"axios": ">= 0.17.0"
}
},
"node_modules/@edx/frontend-component-header/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@edx/frontend-component-header/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@edx/frontend-component-header/node_modules/classnames": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
},
"node_modules/@edx/frontend-component-header/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/@edx/frontend-component-header/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/@edx/frontend-component-header/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@edx/frontend-component-header/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/@edx/frontend-component-header/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@edx/frontend-component-header/node_modules/react-responsive": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.2.0.tgz",
"integrity": "sha512-iagCqVrw4QSjhxKp3I/YK6+ODkWY6G+YPElvdYKiUUbywwh9Ds0M7r26Fj2/7dWFFbOpcGnJE6uE7aMck8j5Qg==",
"dependencies": {
"hyphenate-style-name": "^1.0.0",
"matchmediaquery": "^0.3.0",
"prop-types": "^15.6.1",
"shallow-equal": "^1.1.0"
},
"engines": {
"node": ">= 0.10"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@edx/frontend-component-header/node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/@edx/frontend-component-header/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@edx/frontend-component-header/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@edx/frontend-enterprise-hotjar": {
"version": "1.2.1",
"license": "AGPL-3.0",
@@ -2298,9 +2852,9 @@
}
},
"node_modules/@edx/frontend-lib-content-components": {
"version": "1.169.3",
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.169.3.tgz",
"integrity": "sha512-CgqMs9vEkx9rw6DBlLThKB/omJzMD+mntZGRevF547G/Gl8dt/KSquNZrOiNph1PImXTD1eb2DtSyN7VbYNBaA==",
"version": "1.174.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.174.0.tgz",
"integrity": "sha512-Vwf9XHEYZ4yA9J40AsIfCgn6rF/SPBual2P3/FoniRy2MazfXALA7ikAAWaoGdn0RGfgY51TaTcum4A4ImE0uw==",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
@@ -2336,7 +2890,7 @@
"xmlchecker": "^0.1.0"
},
"peerDependencies": {
"@edx/frontend-platform": "^4.0.0",
"@edx/frontend-platform": "^4.0.0 || ^5.0.0",
"@edx/paragon": "^20.27.0",
"prop-types": "^15.5.10",
"react": "^16.14.0 || ^17.0.0",
@@ -2536,9 +3090,9 @@
}
},
"node_modules/@edx/paragon": {
"version": "20.45.4",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.45.4.tgz",
"integrity": "sha512-ifkkBR4PRGlsFdMwuyYznUMrifyaO9Yy0PyTsP2iD99Pn5ZZMqYOmtvMm8Ek087ABbc/7MIwzxWXMhTvQpNVNw==",
"version": "20.46.2",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.46.2.tgz",
"integrity": "sha512-px+KS/BV1CbiMKgfVgUofyjJi4CHUCUOLRukJbT66VPPqWP4Xon5Rns6uohoratPXMg2kNN46v2L8wIwqKQ4Lw==",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/react-fontawesome": "^0.1.18",
@@ -5513,7 +6067,6 @@
},
"node_modules/ansi-escapes": {
"version": "4.3.2",
"dev": true,
"license": "MIT",
"dependencies": {
"type-fest": "^0.21.3"
@@ -5538,7 +6091,6 @@
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5915,7 +6467,6 @@
},
"node_modules/axios": {
"version": "0.21.4",
"dev": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.14.0"
@@ -6323,7 +6874,6 @@
},
"node_modules/base64-js": {
"version": "1.5.1",
"dev": true,
"funding": [
{
"type": "github",
@@ -6363,7 +6913,6 @@
},
"node_modules/bl": {
"version": "4.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
@@ -6373,7 +6922,6 @@
},
"node_modules/bl/node_modules/readable-stream": {
"version": "3.6.2",
"dev": true,
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
@@ -6548,7 +7096,6 @@
},
"node_modules/buffer": {
"version": "5.7.1",
"dev": true,
"funding": [
{
"type": "github",
@@ -6786,6 +7333,11 @@
"node": ">=10"
}
},
"node_modules/chardet": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="
},
"node_modules/cheerio": {
"version": "1.0.0-rc.12",
"dev": true,
@@ -6822,6 +7374,11 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/child_process": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz",
"integrity": "sha512-Wmza/JzL0SiWz7kl6MhIKT5ceIlnFPJX+lwUGj7Clhy5MMldsSoJR0+uvRzOS5Kv45Mq7t1PoE8TsOA9bzvb6g=="
},
"node_modules/chokidar": {
"version": "3.5.3",
"dev": true,
@@ -7009,6 +7566,36 @@
"webpack": "*"
}
},
"node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
"integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
"dependencies": {
"restore-cursor": "^3.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cli-spinners": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.1.tgz",
"integrity": "sha512-jHgecW0pxkonBJdrKsqxgRX9AcG+u/5k0Q7WPDfi8AogLAdwxEkyYYNWwZ5GvVFoFx2uiY1eNcSK00fh+1+FyQ==",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-width": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz",
"integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==",
"engines": {
"node": ">= 10"
}
},
"node_modules/cliui": {
"version": "7.0.4",
"dev": true,
@@ -7019,6 +7606,14 @@
"wrap-ansi": "^7.0.0"
}
},
"node_modules/clone": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
"integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clone-deep": {
"version": "4.0.1",
"dev": true,
@@ -7907,6 +8502,17 @@
"node": ">= 8"
}
},
"node_modules/defaults": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
"integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==",
"dependencies": {
"clone": "^1.0.2"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/define-lazy-prop": {
"version": "2.0.0",
"dev": true,
@@ -8586,7 +9192,6 @@
},
"node_modules/escape-string-regexp": {
"version": "1.0.5",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.0"
@@ -9738,6 +10343,30 @@
"node": ">=0.10.0"
}
},
"node_modules/external-editor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
"integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
"dependencies": {
"chardet": "^0.7.0",
"iconv-lite": "^0.4.24",
"tmp": "^0.0.33"
},
"engines": {
"node": ">=4"
}
},
"node_modules/external-editor/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/extglob": {
"version": "2.0.4",
"dev": true,
@@ -9788,7 +10417,6 @@
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"dev": true,
"license": "MIT"
},
"node_modules/fast-defer": {
@@ -9855,6 +10483,20 @@
"bser": "2.1.1"
}
},
"node_modules/figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
"integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==",
"dependencies": {
"escape-string-regexp": "^1.0.5"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"dev": true,
@@ -9885,6 +10527,11 @@
"webpack": "^4.0.0 || ^5.0.0"
}
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
},
"node_modules/file-selector": {
"version": "0.6.0",
"license": "MIT",
@@ -11251,7 +11898,6 @@
},
"node_modules/ieee754": {
"version": "1.2.1",
"dev": true,
"funding": [
{
"type": "github",
@@ -11458,6 +12104,108 @@
"dev": true,
"license": "ISC"
},
"node_modules/inquirer": {
"version": "8.2.6",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz",
"integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==",
"dependencies": {
"ansi-escapes": "^4.2.1",
"chalk": "^4.1.1",
"cli-cursor": "^3.1.0",
"cli-width": "^3.0.0",
"external-editor": "^3.0.3",
"figures": "^3.0.0",
"lodash": "^4.17.21",
"mute-stream": "0.0.8",
"ora": "^5.4.1",
"run-async": "^2.4.0",
"rxjs": "^7.5.5",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0",
"through": "^2.3.6",
"wrap-ansi": "^6.0.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/inquirer/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/inquirer/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/inquirer/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/inquirer/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/inquirer/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/inquirer/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/inquirer/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/internal-slot": {
"version": "1.0.5",
"dev": true,
@@ -11591,7 +12339,6 @@
},
"node_modules/is-buffer": {
"version": "2.0.5",
"dev": true,
"funding": [
{
"type": "github",
@@ -11725,7 +12472,6 @@
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -11754,6 +12500,14 @@
"node": ">=0.10.0"
}
},
"node_modules/is-interactive": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz",
"integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==",
"engines": {
"node": ">=8"
}
},
"node_modules/is-invalid-path": {
"version": "0.1.0",
"dev": true,
@@ -11980,6 +12734,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/is-unicode-supported": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-valid-path": {
"version": "0.1.1",
"dev": true,
@@ -14674,6 +15439,85 @@
"version": "4.7.0",
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
"integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
"dependencies": {
"chalk": "^4.1.0",
"is-unicode-supported": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-symbols/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/log-symbols/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/log-symbols/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/log-symbols/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/log-symbols/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/log-symbols/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"license": "MIT",
@@ -15005,7 +15849,6 @@
},
"node_modules/mimic-fn": {
"version": "2.1.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -15220,6 +16063,11 @@
"multicast-dns": "cli.js"
}
},
"node_modules/mute-stream": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
},
"node_modules/mux.js": {
"version": "6.0.1",
"license": "Apache-2.0",
@@ -15884,7 +16732,6 @@
},
"node_modules/onetime": {
"version": "5.1.2",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-fn": "^2.1.0"
@@ -15944,6 +16791,100 @@
"node": ">= 0.8.0"
}
},
"node_modules/ora": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
"integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==",
"dependencies": {
"bl": "^4.1.0",
"chalk": "^4.1.0",
"cli-cursor": "^3.1.0",
"cli-spinners": "^2.5.0",
"is-interactive": "^1.0.0",
"is-unicode-supported": "^0.1.0",
"log-symbols": "^4.1.0",
"strip-ansi": "^6.0.0",
"wcwidth": "^1.0.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ora/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ora/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/ora/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/ora/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/ora/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/ora/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/os-tmpdir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/p-each-series": {
"version": "2.2.0",
"dev": true,
@@ -17902,15 +18843,6 @@
"version": "1.0.4",
"license": "MIT"
},
"node_modules/react-ranger": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/react-ranger/-/react-ranger-2.1.0.tgz",
"integrity": "sha512-d8ezhyX3v/KlN8SkyoE5e8Dybsdmn94eUAqBDsAPrVhZde8sVt6pLJw4fC784KiMmS4LyAjvtjGxhAEqjjGYgw==",
"hasInstallScript": true,
"peerDependencies": {
"react": "^16.6.3"
}
},
"node_modules/react-redux": {
"version": "7.1.3",
"license": "MIT",
@@ -18671,6 +19603,18 @@
"node": ">=0.10.0"
}
},
"node_modules/restore-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
"integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
"dependencies": {
"onetime": "^5.1.0",
"signal-exit": "^3.0.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ret": {
"version": "0.1.15",
"dev": true,
@@ -18738,6 +19682,14 @@
"rtlcss": "bin/rtlcss.js"
}
},
"node_modules/run-async": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
"integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/run-node": {
"version": "1.0.0",
"dev": true,
@@ -18778,6 +19730,14 @@
"individual": "^2.0.0"
}
},
"node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"license": "MIT"
@@ -18811,7 +19771,6 @@
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"dev": true,
"license": "MIT"
},
"node_modules/sane": {
@@ -19420,7 +20379,6 @@
},
"node_modules/signal-exit": {
"version": "3.0.7",
"dev": true,
"license": "ISC"
},
"node_modules/simple-concat": {
@@ -20104,7 +21062,6 @@
},
"node_modules/string-width": {
"version": "4.2.3",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -20117,7 +21074,6 @@
},
"node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0",
"dev": true,
"license": "MIT"
},
"node_modules/string.prototype.matchall": {
@@ -20182,7 +21138,6 @@
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -20930,6 +21885,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
},
"node_modules/thunky": {
"version": "1.1.0",
"dev": true,
@@ -20947,6 +21907,17 @@
"version": "5.10.5",
"license": "LGPL-2.1"
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
"dependencies": {
"os-tmpdir": "~1.0.2"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"dev": true,
@@ -21140,7 +22111,6 @@
},
"node_modules/type-fest": {
"version": "0.21.3",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
@@ -21680,6 +22650,14 @@
"minimalistic-assert": "^1.0.0"
}
},
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
"integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==",
"dependencies": {
"defaults": "^1.0.3"
}
},
"node_modules/webidl-conversions": {
"version": "6.1.0",
"dev": true,

View File

@@ -35,9 +35,10 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "12.0.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.169.3",
"@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",
@@ -49,6 +50,7 @@
"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",
@@ -58,7 +60,6 @@
"react-datepicker": "^4.13.0",
"react-dom": "16.14.0",
"react-helmet": "^6.1.0",
"react-ranger": "^2.1.0",
"react-redux": "7.1.3",
"react-responsive": "8.1.0",
"react-router": "5.2.0",
@@ -67,6 +68,7 @@
"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"
},

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

@@ -5,8 +5,8 @@ import { useDispatch, useSelector } from 'react-redux';
import {
useLocation,
} from 'react-router-dom';
import { Footer } from '@edx/frontend-lib-content-components';
import Header from './studio-header/Header';
import { 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';
@@ -37,21 +37,6 @@ AppHeader.defaultProps = {
courseOrg: null,
};
const AppFooter = () => (
<div className="mt-6">
<Footer
marketingBaseUrl={process.env.MARKETING_SITE_BASE_URL}
termsOfServiceUrl={process.env.TERMS_OF_SERVICE_URL}
privacyPolicyUrl={process.env.PRIVACY_POLICY_URL}
supportEmail={process.env.SUPPORT_EMAIL}
platformName={process.env.SITE_NAME}
lmsBaseUrl={process.env.LMS_BASE_URL}
studioBaseUrl={process.env.STUDIO_BASE_URL}
showAccessibilityPage={process.env.ENABLE_ACCESSIBILITY_PAGE === 'true'}
/>
</div>
);
const CourseAuthoringPage = ({ courseId, children }) => {
const dispatch = useDispatch();
@@ -91,7 +76,7 @@ const CourseAuthoringPage = ({ courseId, children }) => {
)
)}
{children}
{!inProgress && showHeader && <AppFooter />}
{!inProgress && showHeader && <StudioFooter />}
</div>
);
};

View File

@@ -15,6 +15,8 @@ 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:
@@ -99,16 +101,10 @@ const CourseAuthoringRoutes = ({ courseId }) => {
<AdvancedSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/import`}>
{process.env.ENABLE_NEW_IMPORT_PAGE === 'true'
&& (
<Placeholder />
)}
<CourseImportPage courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/export`}>
{process.env.ENABLE_NEW_EXPORT_PAGE === 'true'
&& (
<Placeholder />
)}
<CourseExportPage courseId={courseId} />
</PageRoute>
</Switch>
</CourseAuthoringPage>

View File

@@ -65,9 +65,7 @@ describe('<CourseAuthoringRoutes>', () => {
store = initializeStore();
});
// TODO: This test needs to be corrected.
// The problem arose after moving new commits (https://github.com/raccoongang/frontend-app-course-authoring/pull/25)
it.skip('renders the PagesAndResources component when the pages and resources route is active', () => {
fit('renders the PagesAndResources component when the pages and resources route is active', () => {
render(
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/course/${courseId}/pages-and-resources`]}>
@@ -76,7 +74,7 @@ describe('<CourseAuthoringRoutes>', () => {
</AppProvider>,
);
expect(screen.queryByText(pagesAndResourcesMockText)).toBeInTheDocument();
expect(screen.getByText(pagesAndResourcesMockText)).toBeVisible();
expect(screen.queryByText(proctoredExamSeetingsMockText)).not.toBeInTheDocument();
expect(mockComponentFn).toHaveBeenCalledWith(
expect.objectContaining({
@@ -85,9 +83,7 @@ describe('<CourseAuthoringRoutes>', () => {
);
});
// TODO: This test needs to be corrected.
// The problem arose after moving new commits (https://github.com/raccoongang/frontend-app-course-authoring/pull/25)
it.skip('renders the ProctoredExamSettings component when the proctored exam settings route is active', () => {
it('renders the ProctoredExamSettings component when the proctored exam settings route is active', () => {
render(
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/course/${courseId}/proctored-exam-settings`]}>

View File

@@ -9,6 +9,7 @@ import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/
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';
@@ -23,6 +24,7 @@ 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();
@@ -36,6 +38,9 @@ const AdvancedSettings = ({ intl, courseId }) => {
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));

View File

@@ -40,16 +40,11 @@
.form-control {
min-height: 2.75rem;
width: $setting-form-control-width;
}
.pgn__card-section {
max-width: $setting-form-control-width;
flex-grow: 1;
}
.pgn__card-header {
padding: 0 0 0 1.5rem;
flex-grow: 1;
}
.pgn__card-status {
@@ -59,8 +54,6 @@
.pgn__card-header-content {
margin-top: 1.438rem;
margin-bottom: 1.438rem;
display: flex;
flex-direction: revert;
}
}

View File

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

View File

@@ -58,8 +58,9 @@ const SettingCard = ({
return (
<li className="field-group course-advanced-policy-list-item">
<Card className="flex-column setting-card">
<Card.Body className="d-flex">
<Card.Body className="d-flex row m-0 align-items-center">
<Card.Header
className="col-6"
title={(
<ActionRow>
{capitalize(displayName)}
@@ -86,10 +87,11 @@ const SettingCard = ({
dangerouslySetInnerHTML={{ __html: help }}
/>
</ModalPopup>
<ActionRow.Spacer />
</ActionRow>
)}
/>
<Card.Section>
<Card.Section className="col-6 flex-grow-1">
<Form.Group className="m-0">
<Form.Control
as={TextareaAutosize}

View File

@@ -6,7 +6,7 @@ import {
} from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import HelpSidebar from '../../generic/help-sidebar';
import { HelpSidebar } from '../../generic/help-sidebar';
import messages from './messages';
const SettingsSidebar = ({ intl, courseId, proctoredExamSettingsUrl }) => (

View File

@@ -4,8 +4,9 @@ export const DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss\\Z';
export const COMMA_SEPARATED_DATE_FORMAT = 'MMMM D, YYYY';
export const DEFAULT_EMPTY_WYSIWYG_VALUE = '<p>&nbsp;</p>';
export const STATEFUL_BUTTON_STATES = {
pending: 'pending',
default: 'default',
pending: 'pending',
error: 'error',
};
export const USER_ROLES = {
@@ -23,3 +24,13 @@ export const NOTIFICATION_MESSAGES = {
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

@@ -9,6 +9,7 @@ import {
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';
@@ -18,10 +19,14 @@ 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,
@@ -57,7 +62,7 @@ const CourseTeam = ({ courseId }) => {
return (
<>
<Container size="xl" className="m-4">
<Container size="xl" className="px-4">
<section className="course-team-container mb-4">
<Layout
lg={[{ span: 9 }, { span: 3 }]}
@@ -74,7 +79,7 @@ const CourseTeam = ({ courseId }) => {
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={isAllowActions && (
<Button
variant="outline-success"
variant="primary"
iconBefore={IconAdd}
size="sm"
onClick={openForm}

View File

@@ -16,7 +16,7 @@ const AddTeamMember = ({ onFormOpen, isButtonDisable }) => {
<span className="text-gray-500 small">{intl.formatMessage(messages.description)}</span>
</div>
<Button
variant="outline-success"
variant="primary"
iconBefore={IconAdd}
onClick={onFormOpen}
disabled={isButtonDisable}

View File

@@ -4,4 +4,14 @@ export const MODAL_TYPES = {
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

@@ -1,11 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Badge, Button, MailtoLink } from '@edx/paragon';
import { DeleteOutline as DeleteOutlineIcon } from '@edx/paragon/icons';
import {
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';
import { USER_ROLES, BADGE_STATES } from '../constants';
const CourseTeamMember = ({
userName,
@@ -19,16 +25,17 @@ const CourseTeamMember = ({
}) => {
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 variant={isAdminRole ? BADGE_STATES.danger : BADGE_STATES.secondary} className="badge-current-user">
<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">{intl.formatMessage(messages.roleYou)}</span>
<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>
@@ -45,13 +52,13 @@ const CourseTeamMember = ({
>
{isAdminRole ? intl.formatMessage(messages.removeButton) : intl.formatMessage(messages.addButton)}
</Button>
<Button
className="delete-button"
variant="tertiary"
size="sm"
data-testid="delete-button"
iconBefore={DeleteOutlineIcon}
<IconButtonWithTooltip
src={DeleteOutline}
tooltipContent={intl.formatMessage(messages.deleteUserButton)}
onClick={() => onDelete(email)}
iconAs={Icon}
alt={intl.formatMessage(messages.deleteUserButton)}
data-testid="delete-button"
/>
</div>
) : (

View File

@@ -29,7 +29,6 @@
}
.badge-current-user {
color: $gray-100;
margin-left: .25rem;
}
@@ -49,15 +48,5 @@
.member-actions {
display: flex;
gap: $spacer;
.delete-button {
display: flex;
align-items: center;
justify-content: center;
& > span {
margin: 0;
}
}
}
}

View File

@@ -25,6 +25,10 @@ const messages = defineMessages({
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

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import HelpSidebar from '../../generic/help-sidebar';
import { HelpSidebar } from '../../generic/help-sidebar';
import messages from './messages';
const CourseTeamSideBar = ({ courseId, isOwnershipHint, isShowInitialSidebar }) => {

View File

@@ -45,7 +45,6 @@ const InfoModal = ({
</Button>
{modalType === MODAL_TYPES.delete && (
<Button
variant="danger"
onClick={(e) => {
e.preventDefault();
onDeleteSubmit();

View File

@@ -3,7 +3,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
deleteModalTitle: {
id: 'course-authoring.course-team.member.button.remove',
defaultMessage: 'Are you sure?',
defaultMessage: 'Delete course team member',
},
deleteModalMessage: {
id: 'course-authoring.course-team.delete-modal.message',

View File

@@ -23,7 +23,7 @@ const getInfoModalSettings = (modalType, currentEmail, errorMessage, courseName,
return {
title: intl.formatMessage(messages.deleteModalTitle),
message: intl.formatMessage(messages.deleteModalMessage, { email: currentEmail, courseName }),
variant: 'danger',
variant: '',
closeButtonText: intl.formatMessage(messages.deleteModalCancelButton),
submitButtonText: intl.formatMessage(messages.deleteModalDeleteButton),
closeButtonVariant: 'tertiary',
@@ -34,7 +34,7 @@ const getInfoModalSettings = (modalType, currentEmail, errorMessage, courseName,
message: errorMessage,
variant: 'danger',
closeButtonText: intl.formatMessage(messages.errorModalOkButton),
closeButtonVariant: 'danger',
closeButtonVariant: 'primary',
};
case MODAL_TYPES.warning:
return {

View File

@@ -9,6 +9,7 @@ import {
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';
@@ -23,10 +24,14 @@ 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,
@@ -58,7 +63,7 @@ const CourseUpdates = ({ courseId }) => {
return (
<>
<Container size="xl" className="m-4">
<Container size="xl" className="px-4">
<section className="setting-items mb-4 mt-5">
<Layout
lg={[{ span: 12 }]}
@@ -76,7 +81,7 @@ const CourseUpdates = ({ courseId }) => {
instruction={intl.formatMessage(messages.sectionInfo)}
headerActions={(
<Button
variant="outline-primary"
variant="primary"
iconBefore={AddIcon}
size="sm"
onClick={() => handleOpenUpdateForm(REQUEST_TYPES.add_new_update)}

View File

@@ -163,37 +163,39 @@ describe('<CourseUpdates />', () => {
});
it('Add new update form is visible after clicking "New update" button', async () => {
const { getByText, getByRole, getAllByRole } = render(<RootWrapper />);
const { getByText, getByRole, getAllByTestId } = render(<RootWrapper />);
await waitFor(() => {
const editButtons = getAllByRole('button', { name: 'Edit' });
const deleteButtons = getAllByRole('button', { name: 'Delete' });
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();
editButtons.forEach((button) => expect(button).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, getByTestId, getAllByRole,
} = render(<RootWrapper />);
const { getByText, getByRole, getAllByTestId } = render(<RootWrapper />);
await waitFor(() => {
const editHandoutsButton = getByTestId('course-handouts-edit-button');
const editButtons = getAllByRole('button', { name: 'Edit' });
const deleteButtons = getAllByRole('button', { name: 'Delete' });
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();
editButtons.forEach((button) => expect(button).toBeDisabled());
editUpdateButtons.forEach((button) => expect(button).toBeDisabled());
editHandoutsButtons.forEach((button) => expect(button).toBeDisabled());
deleteButtons.forEach((button) => expect(button).toBeDisabled());
expect(getByText('Edit handouts')).toBeInTheDocument();
});
@@ -201,18 +203,20 @@ describe('<CourseUpdates />', () => {
it('Edit update form is visible after clicking "Edit" button', async () => {
const {
getByText, getByRole, getAllByTestId, getAllByRole, queryByText,
getByText, getByRole, getAllByTestId, queryByText,
} = render(<RootWrapper />);
await waitFor(() => {
const editUpdateFirstButton = getAllByTestId('course-update-edit-button')[0];
const editButtons = getAllByRole('button', { name: 'Edit' });
const deleteButtons = getAllByRole('button', { name: 'Delete' });
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();
editButtons.forEach((button) => expect(button).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

@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { Icon, IconButtonWithTooltip } from '@edx/paragon';
import { EditOutline } from '@edx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
@@ -12,16 +13,14 @@ const CourseHandouts = ({ contentForHandouts, onEdit, isDisabledButtons }) => {
<div className="course-handouts" data-testid="course-handouts">
<div className="course-handouts-header">
<h2 className="course-handouts-header__title lead">{intl.formatMessage(messages.handoutsTitle)}</h2>
<Button
className="course-handouts-header__btn"
data-testid="course-handouts-edit-button"
variant="outline-primary"
size="sm"
onClick={onEdit}
<IconButtonWithTooltip
tooltipContent={intl.formatMessage(messages.editButton)}
src={EditOutline}
iconAs={Icon}
disabled={isDisabledButtons}
>
{intl.formatMessage(messages.editButton)}
</Button>
data-testid="course-handouts-edit-button"
onClick={onEdit}
/>
</div>
<div
className="small"

View File

@@ -21,25 +21,25 @@ const renderComponent = (props) => render(
describe('<CourseHandouts />', () => {
it('render CourseHandouts component correctly', () => {
const { getByText, getByRole } = renderComponent();
const { getByText, getByTestId } = renderComponent();
expect(getByText(messages.handoutsTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(handoutsContentMock)).toBeInTheDocument();
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeInTheDocument();
expect(getByTestId('course-handouts-edit-button')).toBeInTheDocument();
});
it('calls the onEdit function when the edit button is clicked', () => {
const { getByRole } = renderComponent();
const { getByTestId } = renderComponent();
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
const editButton = getByTestId('course-handouts-edit-button');
fireEvent.click(editButton);
expect(onEditMock).toHaveBeenCalledTimes(1);
});
it('"Edit" button is disabled when isDisabledButtons is true', () => {
const { getByRole } = renderComponent({ isDisabledButtons: true });
const { getByTestId } = renderComponent({ isDisabledButtons: true });
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
const editButton = getByTestId('course-handouts-edit-button');
expect(editButton).toBeDisabled();
});
});

View File

@@ -1,8 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, Icon } from '@edx/paragon';
import { Icon, IconButtonWithTooltip } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Error as ErrorIcon } from '@edx/paragon/icons/es5';
import { DeleteOutline, EditOutline, Error as ErrorIcon } from '@edx/paragon/icons';
import { isDateForUpdateValid } from './utils';
import messages from './messages';
@@ -27,18 +27,22 @@ const CourseUpdate = ({
</div>
)}
<div className="course-update-header__action">
<Button
variant="outline-primary"
size="sm"
onClick={onEdit}
<IconButtonWithTooltip
tooltipContent={intl.formatMessage(messages.editButton)}
src={EditOutline}
iconAs={Icon}
disabled={isDisabledButtons}
data-testid="course-update-edit-button"
>
{intl.formatMessage(messages.editButton)}
</Button>
<Button variant="outline-primary" size="sm" onClick={onDelete} disabled={isDisabledButtons}>
{intl.formatMessage(messages.deleteButton)}
</Button>
onClick={onEdit}
/>
<IconButtonWithTooltip
tooltipContent={intl.formatMessage(messages.deleteButton)}
src={DeleteOutline}
iconAs={Icon}
disabled={isDisabledButtons}
data-testid="course-update-delete-button"
onClick={onDelete}
/>
</div>
</div>
{Boolean(contentForUpdate) && (

View File

@@ -25,20 +25,20 @@ const renderComponent = (props) => render(
describe('<CourseUpdate />', () => {
it('render CourseUpdate component correctly', () => {
const { getByText, getByRole } = renderComponent();
const { getByText, getByTestId } = renderComponent();
expect(getByText(dateForUpdateMock)).toBeInTheDocument();
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeInTheDocument();
expect(getByTestId('course-update-edit-button')).toBeInTheDocument();
expect(getByTestId('course-update-delete-button')).toBeInTheDocument();
});
it('render CourseUpdate component without content correctly', () => {
const { getByText, queryByTestId, getByRole } = renderComponent({ contentForUpdate: '' });
const { getByText, queryByTestId, getByTestId } = renderComponent({ contentForUpdate: '' });
expect(getByText(dateForUpdateMock)).toBeInTheDocument();
expect(queryByTestId('course-update-content')).not.toBeInTheDocument();
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeInTheDocument();
expect(getByTestId('course-update-edit-button')).toBeInTheDocument();
expect(getByTestId('course-update-delete-button')).toBeInTheDocument();
});
it('render error message when dateForUpdate is inValid', () => {
@@ -48,25 +48,25 @@ describe('<CourseUpdate />', () => {
});
it('calls the onEdit function when the "Edit" button is clicked', () => {
const { getByRole } = renderComponent();
const { getByTestId } = renderComponent();
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
const editButton = getByTestId('course-update-edit-button');
fireEvent.click(editButton);
expect(onEditMock).toHaveBeenCalledTimes(1);
});
it('calls the onDelete function when the "Delete" button is clicked', () => {
const { getByRole } = renderComponent();
const { getByTestId } = renderComponent();
const deleteButton = getByRole('button', { name: messages.deleteButton.defaultMessage });
const deleteButton = getByTestId('course-update-delete-button');
fireEvent.click(deleteButton);
expect(onDeleteMock).toHaveBeenCalledTimes(1);
});
it('"Edit" and "Delete" buttons is disabled when isDisabledButtons is true', () => {
const { getByRole } = renderComponent({ isDisabledButtons: true });
const { getByTestId } = renderComponent({ isDisabledButtons: true });
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeDisabled();
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeDisabled();
expect(getByTestId('course-update-edit-button')).toBeDisabled();
expect(getByTestId('course-update-delete-button')).toBeDisabled();
});
});

View File

@@ -15,7 +15,6 @@ const DeleteModal = ({ isOpen, close, onDeleteSubmit }) => {
return (
<AlertModal
title={intl.formatMessage(messages.deleteModalTitle)}
variant="warning"
isOpen={isOpen}
onClose={close}
footerNode={(
@@ -29,7 +28,7 @@ const DeleteModal = ({ isOpen, close, onDeleteSubmit }) => {
onDeleteSubmit();
}}
>
{intl.formatMessage(messages.okButton)}
{intl.formatMessage(messages.deleteButton)}
</Button>
</ActionRow>
)}

View File

@@ -26,14 +26,14 @@ describe('<DeleteModal />', () => {
expect(getByText(messages.deleteModalTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.deleteModalDescription.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.okButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeInTheDocument();
});
it('calls onDeleteSubmit function when the "Ok" button is clicked', () => {
const { getByRole } = renderComponent();
const okButton = getByRole('button', { name: messages.okButton.defaultMessage });
fireEvent.click(okButton);
const deleteButton = getByRole('button', { name: messages.deleteButton.defaultMessage });
fireEvent.click(deleteButton);
expect(onDeleteSubmitMock).toHaveBeenCalledTimes(1);
});

View File

@@ -13,9 +13,9 @@ const messages = defineMessages({
id: 'course-authoring.course-updates.actions.cancel',
defaultMessage: 'Cancel',
},
okButton: {
id: 'course-authoring.course-updates.button.ok',
defaultMessage: 'Ok',
deleteButton: {
id: 'course-authoring.course-updates.button.delete',
defaultMessage: 'Delete',
},
});

View File

@@ -11,7 +11,7 @@ const messages = defineMessages({
},
sectionInfo: {
id: 'course-authoring.course-updates.section-info',
defaultMessage: 'Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.',
defaultMessage: 'Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions.',
},
newUpdateButton: {
id: 'course-authoring.course-updates.actions.new-update',

View File

@@ -91,7 +91,7 @@ const UpdateForm = ({
</div>
{!isValid && (
<div className="datepicker-field-error">
<Icon src={ErrorIcon} alt={intl.formatMessage(messages.updateFormErrorAltText)} />
<Icon src={ErrorIcon} className="text-danger-500" alt={intl.formatMessage(messages.updateFormErrorAltText)} />
<span className="message-error">{intl.formatMessage(messages.updateFormInValid)}</span>
</div>
)}

View File

@@ -32,10 +32,6 @@
display: flex;
align-items: center;
gap: .25rem;
svg {
color: $warning-300;
}
}
.react-datepicker-popper {

View File

@@ -26,7 +26,7 @@ import Placeholder, {
} from '@edx/frontend-lib-content-components';
import { RequestStatus } from '../data/constants';
import { useModels } from '../generic/model-store';
import { useModels, useModel } from '../generic/model-store';
import { getLoadingStatus, getSavingStatus } from './data/selectors';
import {
addSingleCustomPage,
@@ -40,6 +40,7 @@ import CustomPageCard from './CustomPageCard';
import messages from './messages';
import CustomPagesProvider from './CustomPagesProvider';
import EditModal from './EditModal';
import getPageHeadTitle from '../generic/utils';
const CustomPages = ({
courseId,
@@ -52,6 +53,9 @@ const CustomPages = ({
const [isOpen, open, close] = useToggle(false);
const [isEditModalOpen, openEditModal, closeEditModal] = useToggle(false);
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading));
const { config } = useContext(AppContext);
const { path, url } = useRouteMatch();
const learningCourseURL = `${config.LEARNING_BASE_URL}/course/${courseId}`;

View File

@@ -11,6 +11,7 @@ export const RequestStatus = {
FAILED: 'failed',
DENIED: 'denied',
PENDING: 'pending',
CLEAR: 'clear',
};
/**

View File

@@ -0,0 +1,127 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Container, Layout, Button, Card,
} from '@edx/paragon';
import { ArrowCircleDown as ArrowCircleDownIcon } from '@edx/paragon/icons';
import Cookies from 'universal-cookie';
import { getConfig } from '@edx/frontend-platform';
import { Helmet } from 'react-helmet';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import SubHeader from '../generic/sub-header/SubHeader';
import { RequestStatus } from '../data/constants';
import { useModel } from '../generic/model-store';
import messages from './messages';
import ExportSidebar from './export-sidebar/ExportSidebar';
import {
getCurrentStage, getError, getExportTriggered, getLoadingStatus, getSavingStatus,
} from './data/selectors';
import { startExportingCourse } from './data/thunks';
import { EXPORT_STAGES, LAST_EXPORT_COOKIE_NAME } from './data/constants';
import { updateExportTriggered, updateSavingStatus, updateSuccessDate } from './data/slice';
import ExportModalError from './export-modal-error/ExportModalError';
import ExportFooter from './export-footer/ExportFooter';
import ExportStepper from './export-stepper/ExportStepper';
const CourseExportPage = ({ intl, courseId }) => {
const dispatch = useDispatch();
const exportTriggered = useSelector(getExportTriggered);
const courseDetails = useModel('courseDetails', courseId);
const currentStage = useSelector(getCurrentStage);
const { msg: errorMessage } = useSelector(getError);
const loadingStatus = useSelector(getLoadingStatus);
const savingStatus = useSelector(getSavingStatus);
const cookies = new Cookies();
const isShowExportButton = !exportTriggered || errorMessage || currentStage === EXPORT_STAGES.SUCCESS;
const anyRequestFailed = savingStatus === RequestStatus.FAILED || loadingStatus === RequestStatus.FAILED;
const anyRequestInProgress = savingStatus === RequestStatus.PENDING || loadingStatus === RequestStatus.IN_PROGRESS;
useEffect(() => {
const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME);
if (cookieData) {
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(updateExportTriggered(true));
dispatch(updateSuccessDate(cookieData.date));
}
}, []);
return (
<>
<Helmet>
<title>
{intl.formatMessage(messages.pageTitle, {
headingTitle: intl.formatMessage(messages.headingTitle),
courseName: courseDetails?.name,
siteName: process.env.SITE_NAME,
})}
</title>
</Helmet>
<Container size="xl" className="mt-4 px-4 export">
<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>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
/>
<p className="small">{intl.formatMessage(messages.description1, { studioShortName: getConfig().STUDIO_SHORT_NAME })}</p>
<p className="small">{intl.formatMessage(messages.description2)}</p>
<Card>
<Card.Header
className="h3 px-3 text-black mb-4"
title={intl.formatMessage(messages.titleUnderButton)}
/>
{isShowExportButton && (
<Card.Section className="px-3 py-1">
<Button
size="lg"
block
className="mb-4"
onClick={() => dispatch(startExportingCourse(courseId))}
iconBefore={ArrowCircleDownIcon}
>
{intl.formatMessage(messages.buttonTitle)}
</Button>
</Card.Section>
)}
</Card>
{exportTriggered && <ExportStepper courseId={courseId} />}
<ExportFooter />
</article>
</Layout.Element>
<Layout.Element>
<ExportSidebar courseId={courseId} />
</Layout.Element>
</Layout>
</section>
<ExportModalError courseId={courseId} />
</Container>
<div className="alert-toast">
<InternetConnectionAlert
isFailed={anyRequestFailed}
isQueryPending={anyRequestInProgress}
onInternetConnectionFailed={() => null}
/>
</div>
</>
);
};
CourseExportPage.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
CourseExportPage.defaultProps = {};
export default injectIntl(CourseExportPage);

View File

@@ -0,0 +1,8 @@
@import "./export-stepper/ExportStepper";
@import "./export-footer/ExportFooter";
.export {
.help-sidebar {
margin-top: 7.188rem;
}
}

View File

@@ -0,0 +1,140 @@
import React from 'react';
import { getConfig, 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 { fireEvent, render, waitFor } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { Helmet } from 'react-helmet';
import Cookies from 'universal-cookie';
import initializeStore from '../store';
import stepperMessages from './export-stepper/messages';
import modalErrorMessages from './export-modal-error/messages';
import { getExportStatusApiUrl, postExportCourseApiUrl } from './data/api';
import { EXPORT_STAGES } from './data/constants';
import { exportPageMock } from './__mocks__';
import messages from './messages';
import CourseExportPage from './CourseExportPage';
let store;
let axiosMock;
let cookies;
const courseId = '123';
const courseName = 'About Node JS';
jest.mock('../generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({
name: courseName,
}),
}));
jest.mock('universal-cookie', () => {
const mCookie = {
get: jest.fn(),
set: jest.fn(),
};
return jest.fn(() => mCookie);
});
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<CourseExportPage intl={injectIntl} courseId={courseId} />
</IntlProvider>
</AppProvider>
);
describe('<CourseExportPage />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(postExportCourseApiUrl(courseId))
.reply(200, exportPageMock);
cookies = new Cookies();
cookies.get.mockReturnValue(null);
});
it('should render page title correctly', async () => {
render(<RootWrapper />);
await waitFor(() => {
const helmet = Helmet.peek();
expect(helmet.title).toEqual(
`${messages.headingTitle.defaultMessage} | ${courseName} | ${process.env.SITE_NAME}`,
);
});
});
it('should render without errors', async () => {
const { getByText } = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
const exportPageElement = getByText(messages.headingTitle.defaultMessage, {
selector: 'h2.sub-header-title',
});
expect(exportPageElement).toBeInTheDocument();
expect(getByText(messages.titleUnderButton.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.description2.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.buttonTitle.defaultMessage)).toBeInTheDocument();
});
});
it('should start exporting on click', async () => {
const { getByText, container } = render(<RootWrapper />);
const button = container.querySelector('.btn-primary');
fireEvent.click(button);
expect(getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument();
});
it('should show modal error', async () => {
axiosMock
.onGet(getExportStatusApiUrl(courseId))
.reply(200, { exportStatus: EXPORT_STAGES.EXPORTING, exportError: { rawErrorMsg: 'test error', editUnitUrl: 'http://test-url.test' } });
const { getByText, queryByText, container } = render(<RootWrapper />);
const startExportButton = container.querySelector('.btn-primary');
fireEvent.click(startExportButton);
// eslint-disable-next-line no-promise-executor-return
await new Promise((r) => setTimeout(r, 3500));
expect(getByText(/There has been a failure to export to XML at least one component. It is recommended that you go to the edit page and repair the error before attempting another export. Please check that all components on the page are valid and do not display any error messages. The raw error message is: test error/i));
const closeModalWindowButton = getByText('Return to export');
fireEvent.click(closeModalWindowButton);
expect(queryByText(modalErrorMessages.errorCancelButtonUnit.defaultMessage)).not.toBeInTheDocument();
fireEvent.click(closeModalWindowButton);
});
it('should fetch status without clicking when cookies has', async () => {
cookies.get.mockReturnValue({ date: 1679787000 });
const { getByText } = render(<RootWrapper />);
expect(getByText(stepperMessages.stepperPreparingDescription.defaultMessage)).toBeInTheDocument();
});
it('should show download path for relative path', async () => {
axiosMock
.onGet(getExportStatusApiUrl(courseId))
.reply(200, { exportStatus: EXPORT_STAGES.SUCCESS, exportOutput: '/test-download-path.test' });
const { getByText, container } = render(<RootWrapper />);
const startExportButton = container.querySelector('.btn-primary');
fireEvent.click(startExportButton);
// eslint-disable-next-line no-promise-executor-return
await new Promise((r) => setTimeout(r, 3500));
const downloadButton = getByText(stepperMessages.downloadCourseButtonTitle.defaultMessage);
expect(downloadButton).toBeInTheDocument();
expect(downloadButton.getAttribute('href')).toEqual(`${getConfig().STUDIO_BASE_URL}/test-download-path.test`);
});
it('should show download path for absolute path', async () => {
axiosMock
.onGet(getExportStatusApiUrl(courseId))
.reply(200, { exportStatus: EXPORT_STAGES.SUCCESS, exportOutput: 'http://test-download-path.test' });
const { getByText, container } = render(<RootWrapper />);
const startExportButton = container.querySelector('.btn-primary');
fireEvent.click(startExportButton);
// eslint-disable-next-line no-promise-executor-return
await new Promise((r) => setTimeout(r, 3500));
const downloadButton = getByText(stepperMessages.downloadCourseButtonTitle.defaultMessage);
expect(downloadButton).toBeInTheDocument();
expect(downloadButton.getAttribute('href')).toEqual('http://test-download-path.test');
});
});

View File

@@ -0,0 +1,3 @@
module.exports = {
exportStatus: 1,
};

View File

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

View File

@@ -0,0 +1,19 @@
/* eslint-disable import/prefer-default-export */
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const postExportCourseApiUrl = (courseId) => new URL(`export/${courseId}`, getApiBaseUrl()).href;
export const getExportStatusApiUrl = (courseId) => new URL(`export_status/${courseId}`, getApiBaseUrl()).href;
export async function startCourseExporting(courseId) {
const { data } = await getAuthenticatedHttpClient()
.post(postExportCourseApiUrl(courseId));
return camelCaseObject(data);
}
export async function getExportStatus(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getExportStatusApiUrl(courseId));
return camelCaseObject(data);
}

View File

@@ -0,0 +1,47 @@
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getExportStatus, postExportCourseApiUrl, startCourseExporting } from './api';
let axiosMock;
const courseId = 'course-123';
describe('API Functions', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
afterEach(() => {
jest.clearAllMocks();
});
it('should fetch status on start exporting', async () => {
const data = { exportStatus: 1 };
axiosMock.onPost(postExportCourseApiUrl(courseId)).reply(200, data);
const result = await startCourseExporting(courseId);
expect(axiosMock.history.post[0].url).toEqual(postExportCourseApiUrl(courseId));
expect(result).toEqual(data);
});
it('should fetch on get export status', async () => {
const data = { exportStatus: 2 };
const queryUrl = new URL(`export_status/${courseId}`, getConfig().STUDIO_BASE_URL).href;
axiosMock.onGet(queryUrl).reply(200, data);
const result = await getExportStatus(courseId);
expect(axiosMock.history.get[0].url).toEqual(queryUrl);
expect(result).toEqual(data);
});
});

View File

@@ -0,0 +1,8 @@
export const LAST_EXPORT_COOKIE_NAME = 'lastexport';
export const EXPORT_STAGES = {
PREPARING: 0,
EXPORTING: 1,
COMPRESSING: 2,
SUCCESS: 3,
};
export const SUCCESS_DATE_FORMAT = 'MM/DD/yyyy';

View File

@@ -0,0 +1,8 @@
export const getExportTriggered = (state) => state.courseExport.exportTriggered;
export const getCurrentStage = (state) => state.courseExport.currentStage;
export const getDownloadPath = (state) => state.courseExport.downloadPath;
export const getSuccessDate = (state) => state.courseExport.successDate;
export const getError = (state) => state.courseExport.error;
export const getIsErrorModalOpen = (state) => state.courseExport.isErrorModalOpen;
export const getLoadingStatus = (state) => state.courseExport.loadingStatus;
export const getSavingStatus = (state) => state.courseExport.savingStatus;

View File

@@ -0,0 +1,63 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
exportTriggered: false,
currentStage: 0,
error: { msg: null, unitUrl: null },
downloadPath: null,
successDate: null,
isErrorModalOpen: false,
loadingStatus: '',
savingStatus: '',
};
const slice = createSlice({
name: 'exportPage',
initialState,
reducers: {
updateExportTriggered: (state, { payload }) => {
state.exportTriggered = payload;
},
updateCurrentStage: (state, { payload }) => {
if (payload >= state.currentStage) {
state.currentStage = payload;
}
},
updateDownloadPath: (state, { payload }) => {
state.downloadPath = payload;
},
updateSuccessDate: (state, { payload }) => {
state.successDate = payload;
},
updateError: (state, { payload }) => {
state.error = payload;
},
updateIsErrorModalOpen: (state, { payload }) => {
state.isErrorModalOpen = payload;
},
reset: () => initialState,
updateLoadingStatus: (state, { payload }) => {
state.loadingStatus = payload.status;
},
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
},
});
export const {
updateExportTriggered,
updateCurrentStage,
updateDownloadPath,
updateSuccessDate,
updateError,
updateIsErrorModalOpen,
reset,
updateLoadingStatus,
updateSavingStatus,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,80 @@
import Cookies from 'universal-cookie';
import moment from 'moment';
import { getConfig } from '@edx/frontend-platform';
import { RequestStatus } from '../../data/constants';
import { setExportCookie } from '../utils';
import { EXPORT_STAGES, LAST_EXPORT_COOKIE_NAME } from './constants';
import {
startCourseExporting,
getExportStatus,
} from './api';
import {
updateExportTriggered,
updateCurrentStage,
updateDownloadPath,
updateSuccessDate,
updateError,
updateIsErrorModalOpen,
reset,
updateLoadingStatus,
updateSavingStatus,
} from './slice';
export function startExportingCourse(courseId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
try {
dispatch(reset());
dispatch(updateExportTriggered(true));
const exportData = await startCourseExporting(courseId);
dispatch(updateCurrentStage(exportData.exportStatus));
setExportCookie(moment().valueOf(), exportData.exportStatus === EXPORT_STAGES.SUCCESS);
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function fetchExportStatus(courseId) {
return async (dispatch) => {
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const { exportStatus, exportOutput, exportError } = await getExportStatus(courseId);
dispatch(updateCurrentStage(Math.abs(exportStatus)));
if (exportOutput) {
if (exportOutput.startsWith('/')) {
dispatch(updateDownloadPath(`${getConfig().STUDIO_BASE_URL}${exportOutput}`));
} else {
dispatch(updateDownloadPath(exportOutput));
}
dispatch(updateSuccessDate(moment().valueOf()));
}
const cookies = new Cookies();
const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME);
if (!cookieData?.completed) {
setExportCookie(moment().valueOf(), exportStatus === EXPORT_STAGES.SUCCESS);
}
if (exportError) {
const errorMessage = exportError.rawErrorMsg || exportError;
const errorUnitUrl = exportError.editUnitUrl || null;
dispatch(updateError({ msg: errorMessage, unitUrl: errorUnitUrl }));
dispatch(updateIsErrorModalOpen(true));
}
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
dispatch(updateLoadingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import {
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { Layout } from '@edx/paragon';
import messages from './messages';
const ExportFooter = ({ intl }) => (
<footer className="mt-4 small">
<Layout
lg={[{ span: 5 }, { span: 2 }, { span: 5 }]}
md={[{ span: 5 }, { span: 2 }, { span: 5 }]}
sm={[{ span: 5 }, { span: 2 }, { span: 5 }]}
xs={[{ span: 5 }, { span: 2 }, { span: 5 }]}
xl={[{ span: 5 }, { span: 2 }, { span: 5 }]}
>
<Layout.Element>
<h4>{intl.formatMessage(messages.exportedDataTitle)}</h4>
<ul className="export-footer-list">
<li>{intl.formatMessage(messages.exportedDataItem1)}</li>
<li>{intl.formatMessage(messages.exportedDataItem2)}</li>
<li>{intl.formatMessage(messages.exportedDataItem3)}</li>
<li>{intl.formatMessage(messages.exportedDataItem4)}</li>
<li>{intl.formatMessage(messages.exportedDataItem5)}</li>
<li>{intl.formatMessage(messages.exportedDataItem6)}</li>
<li>{intl.formatMessage(messages.exportedDataItem7)}</li>
</ul>
</Layout.Element>
<Layout.Element />
<Layout.Element>
<h4>{intl.formatMessage(messages.notExportedDataTitle)}</h4>
<ul className="export-footer-list">
<li>{intl.formatMessage(messages.notExportedDataItem1)}</li>
<li>{intl.formatMessage(messages.notExportedDataItem2)}</li>
<li>{intl.formatMessage(messages.notExportedDataItem3)}</li>
<li>{intl.formatMessage(messages.notExportedDataItem4)}</li>
</ul>
</Layout.Element>
</Layout>
</footer>
);
ExportFooter.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(ExportFooter);

View File

@@ -0,0 +1,14 @@
.export-footer-list {
list-style: none;
padding-left: 0;
li {
padding-bottom: .3125rem;
border-bottom: 1px solid #E5E5E5;
margin-bottom: .3125rem;
}
li:last-child {
border-bottom: none;
}
}

View File

@@ -0,0 +1,58 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
exportedDataTitle: {
id: 'course-authoring.export.footer.exportedData.title',
defaultMessage: 'Data exported with your course:',
},
exportedDataItem1: {
id: 'course-authoring.export.footer.exportedData.item.1',
defaultMessage: 'Values from Advanced settings, including MATLAB API keys and LTI passports',
},
exportedDataItem2: {
id: 'course-authoring.export.footer.exportedData.item.2',
defaultMessage: 'Course content (all sections, sub-sections, and units)',
},
exportedDataItem3: {
id: 'course-authoring.export.footer.exportedData.item.3',
defaultMessage: 'Course structure',
},
exportedDataItem4: {
id: 'course-authoring.export.footer.exportedData.item.4',
defaultMessage: 'Individual problems',
},
exportedDataItem5: {
id: 'course-authoring.export.footer.exportedData.item.5',
defaultMessage: 'Pages',
},
exportedDataItem6: {
id: 'course-authoring.export.footer.exportedData.item.6',
defaultMessage: 'Course assets',
},
exportedDataItem7: {
id: 'course-authoring.export.footer.exportedData.item.7',
defaultMessage: 'Course settings',
},
notExportedDataTitle: {
id: 'course-authoring.export.footer.notExportedData.title',
defaultMessage: 'Data not exported with your course:',
},
notExportedDataItem1: {
id: 'course-authoring.export.footer.notExportedData.item.1',
defaultMessage: 'User data',
},
notExportedDataItem2: {
id: 'course-authoring.export.footer.notExportedData.item.2',
defaultMessage: 'Course team data',
},
notExportedDataItem3: {
id: 'course-authoring.export.footer.notExportedData.item.3',
defaultMessage: 'Forum/discussion data',
},
notExportedDataItem4: {
id: 'course-authoring.export.footer.notExportedData.item.4',
defaultMessage: 'Certificates',
},
});
export default messages;

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useDispatch, useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import PropTypes from 'prop-types';
import ModalError from '../../generic/modal-error/ModalError';
import { getError, getIsErrorModalOpen } from '../data/selectors';
import { updateIsErrorModalOpen } from '../data/slice';
import messages from './messages';
const ExportModalError = ({
intl,
courseId,
}) => {
const dispatch = useDispatch();
const isErrorModalOpen = useSelector(getIsErrorModalOpen);
const { msg: errorMessage, unitUrl: unitErrorUrl } = useSelector(getError);
const handleUnitRedirect = () => { window.location.assign(unitErrorUrl); };
const handleRedirectCourseHome = () => { window.location.assign(`${getConfig().STUDIO_BASE_URL}/course/${courseId}`); };
return (
<ModalError
isOpen={isErrorModalOpen}
title={intl.formatMessage(messages.errorTitle)}
message={
intl.formatMessage(
unitErrorUrl
? messages.errorDescriptionUnit
: messages.errorDescriptionNotUnit,
{ errorMessage },
)
}
cancelButtonText={
intl.formatMessage(unitErrorUrl ? messages.errorCancelButtonUnit : messages.errorCancelButtonNotUnit)
}
actionButtonText={
intl.formatMessage(unitErrorUrl ? messages.errorActionButtonUnit : messages.errorActionButtonNotUnit)
}
handleCancel={() => dispatch(updateIsErrorModalOpen(false))}
handleAction={unitErrorUrl ? handleUnitRedirect : handleRedirectCourseHome}
/>
);
};
ExportModalError.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
ExportModalError.defaultProps = {};
export default injectIntl(ExportModalError);

View File

@@ -0,0 +1,34 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
errorTitle: {
id: 'course-authoring.export.modal.error.title',
defaultMessage: 'There has been an error while exporting.',
},
errorDescriptionNotUnit: {
id: 'course-authoring.export.modal.error.description.not.unit',
defaultMessage: 'Your course could not be exported to XML. There is not enough information to identify the failed component. Inspect your course to identify any problematic components and try again. The raw error message is: {errorMessage}',
},
errorDescriptionUnit: {
id: 'course-authoring.export.modal.error.description.unit',
defaultMessage: 'There has been a failure to export to XML at least one component. It is recommended that you go to the edit page and repair the error before attempting another export. Please check that all components on the page are valid and do not display any error messages. The raw error message is: {errorMessage}',
},
errorCancelButtonUnit: {
id: 'course-authoring.export.modal.error.button.cancel.unit',
defaultMessage: 'Return to export',
},
errorCancelButtonNotUnit: {
id: 'course-authoring.export.modal.error.button.cancel.not.unit',
defaultMessage: 'Cancel',
},
errorActionButtonNotUnit: {
id: 'course-authoring.export.modal.error.button.action.not.unit',
defaultMessage: 'Take me to the main course page',
},
errorActionButtonUnit: {
id: 'course-authoring.export.modal.error.button.action.unit',
defaultMessage: 'Correct failed component',
},
});
export default messages;

View File

@@ -0,0 +1,49 @@
import React from 'react';
import {
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Hyperlink } from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { HelpSidebar } from '../../generic/help-sidebar';
import { useHelpUrls } from '../../help-urls/hooks';
import messages from './messages';
const ExportSidebar = ({ intl, courseId }) => {
const { exportCourse: exportLearnMoreUrl } = useHelpUrls(['exportCourse']);
return (
<HelpSidebar courseId={courseId}>
<h4 className="help-sidebar-about-title">{intl.formatMessage(messages.title1)}</h4>
<p className="help-sidebar-about-descriptions">{intl.formatMessage(messages.description1, { studioShortName: getConfig().STUDIO_SHORT_NAME })}</p>
<hr />
<h4 className="help-sidebar-about-title">{intl.formatMessage(messages.exportedContent)}</h4>
<p className="help-sidebar-about-descriptions">{intl.formatMessage(messages.exportedContentHeading)}</p>
<ul className="px-3">
<li className="help-sidebar-about-descriptions">{intl.formatMessage(messages.content1)}</li>
<li className="help-sidebar-about-descriptions">{intl.formatMessage(messages.content2)}</li>
<li className="help-sidebar-about-descriptions">{intl.formatMessage(messages.content3)}</li>
<li className="help-sidebar-about-descriptions">{intl.formatMessage(messages.content4)}</li>
<li className="help-sidebar-about-descriptions">{intl.formatMessage(messages.content5)}</li>
</ul>
<p className="help-sidebar-about-descriptions">{intl.formatMessage(messages.notExportedContent)}</p>
<ul className="px-3">
<li className="help-sidebar-about-descriptions">{intl.formatMessage(messages.content6)}</li>
<li className="help-sidebar-about-descriptions">{intl.formatMessage(messages.content7)}</li>
</ul>
<hr />
<h4 className="help-sidebar-about-title">{intl.formatMessage(messages.openDownloadFile)}</h4>
<p className="help-sidebar-about-descriptions">{intl.formatMessage(messages.openDownloadFileDescription)}</p>
<hr />
<Hyperlink className="small" href={exportLearnMoreUrl} target="_blank" variant="outline-primary">{intl.formatMessage(messages.learnMoreButtonTitle)}</Hyperlink>
</HelpSidebar>
);
};
ExportSidebar.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default injectIntl(ExportSidebar);

View File

@@ -0,0 +1,39 @@
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 messages from './messages';
import ExportSidebar from './ExportSidebar';
const courseId = 'course-123';
let store;
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<ExportSidebar intl={{ formatMessage: jest.fn() }} courseId={courseId} />
</IntlProvider>
</AppProvider>
);
describe('<ExportSidebar />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('render sidebar correctly', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText(messages.title1.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.exportedContentHeading.defaultMessage)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,66 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
title1: {
id: 'course-authoring.export.sidebar.title1',
defaultMessage: 'Why export a course?',
},
description1: {
id: 'course-authoring.export.sidebar.description1',
defaultMessage: 'You may want to edit the XML in your course directly, outside of {studioShortName}. You may want to create a backup copy of your course. Or, you may want to create a copy of your course that you can later import into another course instance and customize.',
},
exportedContent: {
id: 'course-authoring.export.sidebar.exportedContent',
defaultMessage: 'What content is exported?',
},
exportedContentHeading: {
id: 'course-authoring.export.sidebar.exportedContentHeading',
defaultMessage: 'The following content is exported.',
},
content1: {
id: 'course-authoring.export.sidebar.content1',
defaultMessage: 'Course content and structure',
},
content2: {
id: 'course-authoring.export.sidebar.content2',
defaultMessage: 'Course dates',
},
content3: {
id: 'course-authoring.export.sidebar.content3',
defaultMessage: 'Grading policy',
},
content4: {
id: 'course-authoring.export.sidebar.content4',
defaultMessage: 'Any group configurations',
},
content5: {
id: 'course-authoring.export.sidebar.content5',
defaultMessage: 'Settings on the Advanced settings page, including MATLAB API keys and LTI passports',
},
notExportedContent: {
id: 'course-authoring.export.sidebar.notExportedContent',
defaultMessage: 'The following content is not exported.',
},
content6: {
id: 'course-authoring.export.sidebar.content6',
defaultMessage: 'Learner-specific content, such as learner grades and discussion forum data',
},
content7: {
id: 'course-authoring.export.sidebar.content7',
defaultMessage: 'The course team',
},
openDownloadFile: {
id: 'course-authoring.export.sidebar.openDownloadFile',
defaultMessage: 'Opening the downloaded file',
},
openDownloadFileDescription: {
id: 'course-authoring.export.sidebar.openDownloadFileDescription',
defaultMessage: 'Use an archive program to extract the data from the .tar.gz file. Extracted data includes the course.xml file, as well as subfolders that contain course content.',
},
learnMoreButtonTitle: {
id: 'course-authoring.export.sidebar.learnMoreButtonTitle',
defaultMessage: 'Learn more about exporting a course',
},
});
export default messages;

View File

@@ -0,0 +1,103 @@
import React, { useEffect } from 'react';
import {
FormattedDate,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { Button } from '@edx/paragon';
import CourseStepper from '../../generic/course-stepper';
import {
getCurrentStage, getDownloadPath, getError, getLoadingStatus, getSuccessDate,
} from '../data/selectors';
import { fetchExportStatus } from '../data/thunks';
import { EXPORT_STAGES } from '../data/constants';
import { RequestStatus } from '../../data/constants';
import messages from './messages';
const ExportStepper = ({ intl, courseId }) => {
const currentStage = useSelector(getCurrentStage);
const downloadPath = useSelector(getDownloadPath);
const successDate = useSelector(getSuccessDate);
const loadingStatus = useSelector(getLoadingStatus);
const { msg: errorMessage } = useSelector(getError);
const dispatch = useDispatch();
const isStopFetching = currentStage === EXPORT_STAGES.SUCCESS
|| loadingStatus === RequestStatus.FAILED
|| errorMessage;
useEffect(() => {
const id = setInterval(() => {
if (isStopFetching) {
clearInterval(id);
} else {
dispatch(fetchExportStatus(courseId));
}
}, 3000);
return () => clearInterval(id);
});
let successTitle = intl.formatMessage(messages.stepperSuccessTitle);
const localizedSuccessDate = successDate ? (
<FormattedDate
value={successDate}
year="2-digit"
month="2-digit"
day="2-digit"
hour="numeric"
minute="numeric"
/>
) : null;
if (localizedSuccessDate && currentStage === EXPORT_STAGES.SUCCESS) {
const successWithDate = (
<>
{successTitle} ({localizedSuccessDate})
</>
);
successTitle = successWithDate;
}
const steps = [
{
title: intl.formatMessage(messages.stepperPreparingTitle),
description: intl.formatMessage(messages.stepperPreparingDescription),
key: EXPORT_STAGES.PREPARING,
}, {
title: intl.formatMessage(messages.stepperExportingTitle),
description: intl.formatMessage(messages.stepperExportingDescription),
key: EXPORT_STAGES.EXPORTING,
}, {
title: intl.formatMessage(messages.stepperCompressingTitle),
description: intl.formatMessage(messages.stepperCompressingDescription),
key: EXPORT_STAGES.COMPRESSING,
}, {
title: successTitle,
description: intl.formatMessage(messages.stepperSuccessDescription),
key: EXPORT_STAGES.SUCCESS,
},
];
return (
<div>
<h3 className="mt-4">{intl.formatMessage(messages.stepperHeaderTitle)}</h3>
<CourseStepper
courseId={courseId}
steps={steps}
activeKey={currentStage}
errorMessage={errorMessage}
hasError={!!errorMessage}
/>
{downloadPath && currentStage === EXPORT_STAGES.SUCCESS && <Button className="ml-5.5 mt-n2.5" href={downloadPath} download>{intl.formatMessage(messages.downloadCourseButtonTitle)}</Button>}
</div>
);
};
ExportStepper.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default injectIntl(ExportStepper);

View File

@@ -0,0 +1,3 @@
.pgn__stepper-header-step-list {
flex-direction: column;
}

View File

@@ -0,0 +1,38 @@
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 messages from './messages';
import ExportStepper from './ExportStepper';
const courseId = 'course-123';
let store;
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<ExportStepper intl={{ formatMessage: jest.fn() }} courseId={courseId} />
</IntlProvider>
</AppProvider>
);
describe('<ExportStepper />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('render stepper correctly', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText(messages.stepperHeaderTitle.defaultMessage)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,46 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
stepperPreparingTitle: {
id: 'course-authoring.export.stepper.title.preparing',
defaultMessage: 'Preparing',
},
stepperExportingTitle: {
id: 'course-authoring.export.stepper.title.exporting',
defaultMessage: 'Exporting',
},
stepperCompressingTitle: {
id: 'course-authoring.export.stepper.title.compressing',
defaultMessage: 'Compressing',
},
stepperSuccessTitle: {
id: 'course-authoring.export.stepper.title.success',
defaultMessage: 'Success',
},
stepperPreparingDescription: {
id: 'course-authoring.export.stepper.description.preparing',
defaultMessage: 'Preparing to start the export',
},
stepperExportingDescription: {
id: 'course-authoring.export.stepper.description.exporting',
defaultMessage: 'Creating the export data files (You can now leave this page safely, but avoid making drastic changes to content until this export is complete)',
},
stepperCompressingDescription: {
id: 'course-authoring.export.stepper.description.compressing',
defaultMessage: 'Compressing the exported data and preparing it for download',
},
stepperSuccessDescription: {
id: 'course-authoring.export.stepper.description.success',
defaultMessage: 'Your exported course can now be downloaded',
},
downloadCourseButtonTitle: {
id: 'course-authoring.export.stepper.download.button.title',
defaultMessage: 'Download exported course',
},
stepperHeaderTitle: {
id: 'course-authoring.export.stepper.header.title',
defaultMessage: 'Course export status',
},
});
export default messages;

View File

@@ -0,0 +1,34 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
pageTitle: {
id: 'course-authoring.export.page.title',
defaultMessage: '{headingTitle} | {courseName} | {siteName}',
},
headingTitle: {
id: 'course-authoring.export.heading.title',
defaultMessage: 'Course export',
},
headingSubtitle: {
id: 'course-authoring.export.heading.subtitle',
defaultMessage: 'Tools',
},
description1: {
id: 'course-authoring.export.description1',
defaultMessage: 'You can export courses and edit them outside of {studioShortName}. The exported file is a .tar.gz file (that is, a .tar file compressed with GNU Zip) that contains the course structure and content. You can also re-import courses that you\'ve exported.',
},
description2: {
id: 'course-authoring.export.description2',
defaultMessage: 'Caution: When you export a course, information such as MATLAB API keys, LTI passports, annotation secret token strings, and annotation storage URLs are included in the exported data. If you share your exported files, you may also be sharing sensitive or license-specific information.',
},
titleUnderButton: {
id: 'course-authoring.export.title-under-button',
defaultMessage: 'Export my course content',
},
buttonTitle: {
id: 'course-authoring.export.button.title',
defaultMessage: 'Export course content',
},
});
export default messages;

29
src/export-page/utils.js Normal file
View File

@@ -0,0 +1,29 @@
import Cookies from 'universal-cookie';
import moment from 'moment';
import { TIME_FORMAT } from '../constants';
import { LAST_EXPORT_COOKIE_NAME, SUCCESS_DATE_FORMAT } from './data/constants';
/**
* Sets an export-related cookie with the provided information.
*
* @param {Date} date - Date of export.
* @param {boolean} completed - Indicates if export was completed successfully.
* @returns {void}
*/
export const setExportCookie = (date, completed) => {
const cookies = new Cookies();
cookies.set(LAST_EXPORT_COOKIE_NAME, { date, completed }, { path: window.location.pathname });
};
/**
* Formats a Unix timestamp as a formatted success date string.
*
* @param {number} unixDate - Unix timestamp to be formatted.
* @returns {string|null} Formatted success date string, including date and time in UTC, or null if the input is falsy.
*/
export const getFormattedSuccessDate = (unixDate) => {
const formattedDate = moment(unixDate).utc().format(SUCCESS_DATE_FORMAT);
const formattedTime = moment(unixDate).utc().format(TIME_FORMAT);
return unixDate ? ` (${formattedDate} at ${formattedTime} UTC)` : null;
};

View File

@@ -0,0 +1,51 @@
import Cookies from 'universal-cookie';
import moment from 'moment';
import { LAST_EXPORT_COOKIE_NAME } from './data/constants';
import { setExportCookie, getFormattedSuccessDate } from './utils';
global.window = Object.create(window);
Object.defineProperty(window, 'location', {
value: {
pathname: '/some-path',
},
});
describe('setExportCookie', () => {
it('should set the export cookie with the provided date and completed status', () => {
const cookiesSetMock = jest.spyOn(Cookies.prototype, 'set');
const date = '2023-07-24';
const completed = true;
setExportCookie(date, completed);
expect(cookiesSetMock).toHaveBeenCalledWith(
LAST_EXPORT_COOKIE_NAME,
{ date, completed },
{ path: '/some-path' },
);
cookiesSetMock.mockRestore();
});
});
describe('getFormattedSuccessDate', () => {
it('should return formatted success date with valid input', () => {
const mockCurrentUtcDate = moment.utc('2023-07-24T12:34:56');
const momentMock = jest.spyOn(moment, 'utc').mockReturnValue(mockCurrentUtcDate);
const unixDate = 1679787000;
const expectedFormattedDate = ' (01/20/1970 at 10:36 UTC)';
const result = getFormattedSuccessDate(unixDate);
expect(result).toBe(expectedFormattedDate);
momentMock.mockRestore();
});
it('should return null with null input', () => {
const unixDate = null;
const result = getFormattedSuccessDate(unixDate);
expect(result).toBeNull();
});
});

View File

@@ -1,12 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
export const fileInput = ({
export const useFileInput = ({
onAddFile,
setSelectedRows,
setAddOpen,
}) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const ref = React.useRef();
const click = () => ref.current.click();
const addFile = (e) => {

View File

@@ -12,6 +12,7 @@ const FileMenu = ({
externalUrl,
handleLock,
locked,
onDownload,
openAssetInfo,
openDeleteConfirmation,
portableUrl,
@@ -40,7 +41,7 @@ const FileMenu = ({
>
{intl.formatMessage(messages.copyWebUrlTitle)}
</Dropdown.Item>
<Dropdown.Item href={externalUrl} target="_blank" download>
<Dropdown.Item onClick={onDownload}>
{intl.formatMessage(messages.downloadTitle)}
</Dropdown.Item>
<Dropdown.Item onClick={handleLock}>
@@ -64,6 +65,7 @@ FileMenu.propTypes = {
externalUrl: PropTypes.string.isRequired,
handleLock: PropTypes.func.isRequired,
locked: PropTypes.bool.isRequired,
onDownload: PropTypes.func.isRequired,
openAssetInfo: PropTypes.func.isRequired,
openDeleteConfirmation: PropTypes.func.isRequired,
portableUrl: PropTypes.string.isRequired,

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import isEmpty from 'lodash/isEmpty';
@@ -17,20 +17,22 @@ import {
import Placeholder, { ErrorAlert } from '@edx/frontend-lib-content-components';
import { RequestStatus } from '../data/constants';
import { useModels } from '../generic/model-store';
import { useModels, useModel } from '../generic/model-store';
import {
addAssetFile,
deleteAssetFile,
fetchAssets,
resetErrors,
getUsagePaths,
updateAssetLock,
updateAssetOrder,
fetchAssetDownload,
} from './data/thunks';
import { sortFiles } from './data/utils';
import messages from './messages';
import FileInfo from './FileInfo';
import FileInput, { fileInput } from './FileInput';
import FileInput, { useFileInput } from './FileInput';
import FilesAndUploadsProvider from './FilesAndUploadsProvider';
import {
GalleryCard,
@@ -38,6 +40,8 @@ import {
TableActions,
} from './table-components';
import ApiStatusToast from './ApiStatusToast';
import { clearErrors } from './data/slice';
import getPageHeadTitle from '../generic/utils';
const FilesAndUploads = ({
courseId,
@@ -59,6 +63,9 @@ const FilesAndUploads = ({
const [selectedRows, setSelectedRows] = useState([]);
const [isDeleteConfirmationOpen, openDeleteConfirmation, closeDeleteConfirmation] = useToggle(false);
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading));
useEffect(() => {
dispatch(fetchAssets(courseId));
}, [courseId]);
@@ -72,7 +79,7 @@ const FilesAndUploads = ({
usageStatus: usagePathStatus,
errors: errorMessages,
} = useSelector(state => state.assets);
const fileInputControl = fileInput({
const fileInputControl = useFileInput({
onAddFile: (file) => dispatch(addAssetFile(courseId, file, totalCount)),
setSelectedRows,
setAddOpen,
@@ -95,25 +102,18 @@ const FilesAndUploads = ({
const handleBulkDelete = () => {
closeDeleteConfirmation();
setDeleteOpen();
dispatch(resetErrors({ errorType: 'delete' }));
const assetIdsToDelete = selectedRows.map(row => row.original.id);
assetIdsToDelete.forEach(id => dispatch(deleteAssetFile(courseId, id, totalCount)));
};
const handleBulkDownload = (selectedFlatRows) => {
selectedFlatRows.forEach(row => {
const { externalUrl } = row.original;
const link = document.createElement('a');
link.target = '_blank';
link.download = true;
link.href = externalUrl;
link.click();
});
/* ********** TODO ***********
* implement a zip file function when there are multiple files
*/
};
const handleBulkDownload = useCallback(async (selectedFlatRows) => {
dispatch(resetErrors({ errorType: 'download' }));
dispatch(fetchAssetDownload({ selectedRows: selectedFlatRows, courseId }));
}, []);
const handleLockedAsset = (assetId, locked) => {
dispatch(clearErrors({ errorType: 'lock' }));
dispatch(updateAssetLock({ courseId, assetId, locked }));
};
@@ -123,6 +123,7 @@ const FilesAndUploads = ({
};
const handleOpenAssetInfo = (original) => {
dispatch(resetErrors({ errorType: 'usageMetrics' }));
setSelectedRows([{ original }]);
dispatch(getUsagePaths({ asset: original, courseId, setSelectedRows }));
openAssetInfo();
@@ -146,6 +147,7 @@ const FilesAndUploads = ({
<GalleryCard
{...{
handleLockedAsset,
handleBulkDownload,
handleOpenDeleteConfirmation,
handleOpenAssetInfo,
className,
@@ -158,6 +160,7 @@ const FilesAndUploads = ({
<ListCard
{...{
handleLockedAsset,
handleBulkDownload,
handleOpenDeleteConfirmation,
handleOpenAssetInfo,
className,
@@ -183,8 +186,8 @@ const FilesAndUploads = ({
isError={addAssetStatus === RequestStatus.FAILED}
>
<ul className="p-0">
{errorMessages.upload.map(message => (
<li style={{ listStyle: 'none' }}>
{errorMessages.add.map(message => (
<li key={`add-error-${message}`} style={{ listStyle: 'none' }}>
{intl.formatMessage(messages.errorAlertMessage, { message })}
</li>
))}
@@ -196,7 +199,7 @@ const FilesAndUploads = ({
>
<ul className="p-0">
{errorMessages.delete.map(message => (
<li style={{ listStyle: 'none' }}>
<li key={`delete-error-${message}`} style={{ listStyle: 'none' }}>
{intl.formatMessage(messages.errorAlertMessage, { message })}
</li>
))}
@@ -208,12 +211,16 @@ const FilesAndUploads = ({
>
<ul className="p-0">
{errorMessages.lock.map(message => (
<li style={{ listStyle: 'none' }}>
<li key={`lock-error-${message}`} style={{ listStyle: 'none' }}>
{intl.formatMessage(messages.errorAlertMessage, { message })}
</li>
))}
{errorMessages.download.map(message => (
<li key={`download-error-${message}`} style={{ listStyle: 'none' }}>
{intl.formatMessage(messages.errorAlertMessage, { message })}
</li>
))}
</ul>
</ErrorAlert>
<div className="h2">
<FormattedMessage {...messages.heading} />

View File

@@ -8,6 +8,7 @@ import {
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ReactDOM from 'react-dom';
import { saveAs } from 'file-saver';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
@@ -42,6 +43,7 @@ let axiosMock;
let store;
let file;
ReactDOM.createPortal = jest.fn(node => node);
jest.mock('file-saver');
const renderComponent = () => {
render(
@@ -89,22 +91,27 @@ describe('FilesAndUploads', () => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' });
});
it('should return placeholder component', async () => {
renderComponent();
await mockStore(RequestStatus.DENIED);
expect(screen.getByTestId('under-construction-placeholder')).toBeVisible();
});
it('should have Files and uploads title', async () => {
renderComponent();
await emptyMockStore(RequestStatus.SUCCESSFUL);
expect(screen.getByText('Files and uploads')).toBeVisible();
});
it('should render dropzone', async () => {
renderComponent();
await emptyMockStore(RequestStatus.SUCCESSFUL);
expect(screen.getByTestId('files-dropzone')).toBeVisible();
expect(screen.queryByTestId('files-data-table')).toBeNull();
});
it('should upload a single file', async () => {
renderComponent();
await emptyMockStore(RequestStatus.SUCCESSFUL);
@@ -119,10 +126,13 @@ describe('FilesAndUploads', () => {
});
const addStatus = store.getState().assets.addingStatus;
expect(addStatus).toEqual(RequestStatus.SUCCESSFUL);
expect(screen.queryByTestId('files-dropzone')).toBeNull();
expect(screen.getByTestId('files-data-table')).toBeVisible();
});
});
describe('valid assets', () => {
beforeEach(async () => {
initializeMockApp({
@@ -137,27 +147,39 @@ describe('FilesAndUploads', () => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' });
});
afterEach(() => {
saveAs.mockClear();
});
describe('table view', () => {
it('should render table with gallery card', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
expect(screen.getByTestId('files-data-table')).toBeVisible();
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
});
it('should switch table to list view', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
expect(screen.getByTestId('files-data-table')).toBeVisible();
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
expect(screen.queryByTestId('list-card-mOckID1')).toBeNull();
const listButton = screen.getByLabelText('List');
await act(async () => {
fireEvent.click(listButton);
});
expect(screen.queryByTestId('grid-card-mOckID1')).toBeNull();
expect(screen.getByTestId('list-card-mOckID1')).toBeVisible();
});
});
describe('table actions', () => {
it('should upload a single file', async () => {
renderComponent();
@@ -171,17 +193,21 @@ describe('FilesAndUploads', () => {
const addStatus = store.getState().assets.addingStatus;
expect(addStatus).toEqual(RequestStatus.SUCCESSFUL);
});
it('should have disabled action buttons', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage);
expect(actionsButton).toBeVisible();
await waitFor(() => {
fireEvent.click(actionsButton);
});
expect(screen.getByText(messages.downloadTitle.defaultMessage).closest('a')).toHaveClass('disabled');
expect(screen.getByText(messages.deleteTitle.defaultMessage).closest('a')).toHaveClass('disabled');
});
it('delete button should be enabled and delete selected file', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
@@ -189,59 +215,113 @@ describe('FilesAndUploads', () => {
fireEvent.click(selectCardButton);
const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage);
expect(actionsButton).toBeVisible();
await waitFor(() => {
fireEvent.click(actionsButton);
});
const deleteButton = screen.getByText(messages.deleteTitle.defaultMessage).closest('a');
expect(deleteButton).not.toHaveClass('disabled');
axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(204);
await waitFor(() => {
fireEvent.click(deleteButton);
expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible();
fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage));
expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull();
executeThunk(deleteAssetFile(courseId, 'mOckID1', 5), store.dispatch);
});
const deleteStatus = store.getState().assets.deletingStatus;
expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL);
expect(screen.queryByTestId('grid-card-mOckID1')).toBeNull();
});
it('download button should be enabled and download single selected file', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
const selectCardButton = screen.getAllByTestId('datatable-select-column-checkbox-cell')[0];
fireEvent.click(selectCardButton);
const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage);
expect(actionsButton).toBeVisible();
await waitFor(() => {
fireEvent.click(actionsButton);
});
const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a');
expect(downloadButton).not.toHaveClass('disabled');
fireEvent.click(downloadButton);
expect(saveAs).toHaveBeenCalled();
});
it('download button should be enabled and download multiple selected files', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell');
fireEvent.click(selectCardButtons[0]);
fireEvent.click(selectCardButtons[1]);
const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage);
expect(actionsButton).toBeVisible();
await waitFor(() => {
fireEvent.click(actionsButton);
});
const mockResponseData = { ok: true, blob: () => 'Data' };
const mockFetchResponse = Promise.resolve(mockResponseData);
const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a');
expect(downloadButton).not.toHaveClass('disabled');
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
fireEvent.click(downloadButton);
expect(fetch).toHaveBeenCalledTimes(2);
});
it('sort button should be enabled and sort files by name', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage);
expect(sortsButton).toBeVisible();
await waitFor(() => {
fireEvent.click(sortsButton);
expect(screen.getByText(messages.sortModalTitleLabel.defaultMessage)).toBeVisible();
});
const sortNameAscendingButton = screen.getByText(messages.sortByNameAscending.defaultMessage);
fireEvent.click(sortNameAscendingButton);
fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage));
expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).toBeNull();
});
it('sort button should be enabled and sort files by file size', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage);
expect(sortsButton).toBeVisible();
await waitFor(() => {
fireEvent.click(sortsButton);
expect(screen.getByText(messages.sortModalTitleLabel.defaultMessage)).toBeVisible();
});
const sortBySizeDescendingButton = screen.getByText(messages.sortBySizeDescending.defaultMessage);
fireEvent.click(sortBySizeDescendingButton);
fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage));
expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).toBeNull();
});
});
describe('card menu actions', () => {
it('should open asset info', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
expect(assetMenuButton).toBeVisible();
axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usageLocations: ['subsection - unit / block'] });
await waitFor(() => {
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
@@ -253,16 +333,19 @@ describe('FilesAndUploads', () => {
}), store.dispatch);
expect(screen.getAllByLabelText('mOckID1')[0]).toBeVisible();
});
const { usageStatus } = store.getState().assets;
expect(usageStatus).toEqual(RequestStatus.SUCCESSFUL);
expect(screen.getByText('subsection - unit / block')).toBeVisible();
});
it('should open asset info and handle lock checkbox', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
expect(assetMenuButton).toBeVisible();
axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID1`).reply(201, { locked: false });
axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usageLocations: [] });
await waitFor(() => {
@@ -274,6 +357,7 @@ describe('FilesAndUploads', () => {
setSelectedRows: jest.fn(),
}), store.dispatch);
expect(screen.getAllByLabelText('mOckID1')[0]).toBeVisible();
fireEvent.click(screen.getByLabelText('Checkbox'));
executeThunk(updateAssetLock({
courseId,
@@ -282,15 +366,19 @@ describe('FilesAndUploads', () => {
}), store.dispatch);
});
expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible();
const updateStatus = store.getState().assets.updatingStatus;
expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL);
});
it('should unlock asset', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
expect(assetMenuButton).toBeVisible();
await waitFor(() => {
axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID1`).reply(201, { locked: false });
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
@@ -304,12 +392,15 @@ describe('FilesAndUploads', () => {
const updateStatus = store.getState().assets.updatingStatus;
expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL);
});
it('should lock asset', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible();
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3');
expect(assetMenuButton).toBeVisible();
await waitFor(() => {
axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID3`).reply(201, { locked: true });
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
@@ -323,26 +414,48 @@ describe('FilesAndUploads', () => {
const updateStatus = store.getState().assets.updatingStatus;
expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL);
});
it('download button should download file', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
expect(assetMenuButton).toBeVisible();
await waitFor(() => {
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
fireEvent.click(screen.getByText('Download'));
});
expect(saveAs).toHaveBeenCalled();
});
it('delete button should delete file', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
expect(assetMenuButton).toBeVisible();
await waitFor(() => {
axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(204);
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
fireEvent.click(screen.getByTestId('open-delete-confirmation-button'));
expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible();
fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage));
expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull();
executeThunk(deleteAssetFile(courseId, 'mOckID1', 5), store.dispatch);
});
const deleteStatus = store.getState().assets.deletingStatus;
expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL);
expect(screen.queryByTestId('grid-card-mOckID1')).toBeNull();
});
});
describe('api errors', () => {
it('invalid file size should show error', async () => {
const errorMessage = 'File download.png exceeds maximum size of 20 MB.';
@@ -356,8 +469,10 @@ describe('FilesAndUploads', () => {
});
const addStatus = store.getState().assets.addingStatus;
expect(addStatus).toEqual(RequestStatus.FAILED);
expect(screen.getByText('Error')).toBeVisible();
});
it('404 upload should show error', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
@@ -369,35 +484,45 @@ describe('FilesAndUploads', () => {
});
const addStatus = store.getState().assets.addingStatus;
expect(addStatus).toEqual(RequestStatus.FAILED);
expect(screen.getByText('Error')).toBeVisible();
});
it('404 delete should show error', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
expect(assetMenuButton).toBeVisible();
await waitFor(() => {
axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(404);
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
fireEvent.click(screen.getByTestId('open-delete-confirmation-button'));
expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible();
fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage));
expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull();
executeThunk(deleteAssetFile(courseId, 'mOckID1', 5), store.dispatch);
});
const deleteStatus = store.getState().assets.deletingStatus;
expect(deleteStatus).toEqual(RequestStatus.FAILED);
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
expect(screen.getByText('Error')).toBeVisible();
});
it('404 usage path fetch should show error', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible();
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3');
expect(assetMenuButton).toBeVisible();
axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID3/usage`).reply(404);
await waitFor(() => {
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
@@ -411,12 +536,15 @@ describe('FilesAndUploads', () => {
const { usageStatus } = store.getState().assets;
expect(usageStatus).toEqual(RequestStatus.FAILED);
});
it('404 lock update should show error', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible();
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3');
expect(assetMenuButton).toBeVisible();
await waitFor(() => {
axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID3`).reply(404);
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
@@ -429,6 +557,36 @@ describe('FilesAndUploads', () => {
});
const updateStatus = store.getState().assets.updatingStatus;
expect(updateStatus).toEqual(RequestStatus.FAILED);
expect(screen.getByText('Error')).toBeVisible();
});
it('multiple asset file fetch failure should show error', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell');
fireEvent.click(selectCardButtons[0]);
fireEvent.click(selectCardButtons[1]);
const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage);
expect(actionsButton).toBeVisible();
await waitFor(() => {
fireEvent.click(actionsButton);
});
const mockResponseData = { ok: false };
const mockFetchResponse = Promise.resolve(mockResponseData);
const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a');
expect(downloadButton).not.toHaveClass('disabled');
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
await waitFor(() => {
fireEvent.click(downloadButton);
expect(fetch).toHaveBeenCalledTimes(2);
});
const updateStatus = store.getState().assets.updatingStatus;
expect(updateStatus).toEqual(RequestStatus.FAILED);
expect(screen.getByText('Error')).toBeVisible();
});
});

View File

@@ -19,7 +19,11 @@ const UsageMetricsMessage = ({
<FormattedMessage {...messages.usageNotInUseMessage} />
) : (
<ul className="p-0">
{usageLocations.map((location) => (<li style={{ listStyle: 'none' }}>{location}</li>))}
{usageLocations.map(location => (
<li key={`usage-location-${location}`} style={{ listStyle: 'none' }}>
{location}
</li>
))}
</ul>
);
} else if (usagePathStatus === RequestStatus.FAILED) {

View File

@@ -2,6 +2,9 @@
import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import JSZip from 'jszip';
import saveAs from 'file-saver';
ensureConfig([
'STUDIO_BASE_URL',
], 'Course Apps API service');
@@ -20,6 +23,62 @@ export async function getAssets(courseId, totalCount) {
.get(`${getAssetsUrl(courseId)}?page_size=${pageCount}`);
return camelCaseObject(data);
}
/**
* Fetch asset file.
* @param {blockId} courseId Course ID for the course to operate on
*/
export async function getDownload(selectedRows, courseId) {
const downloadErrors = [];
if (selectedRows?.length > 1) {
const zip = new JSZip();
const date = new Date().toString();
const folder = zip.folder(`${courseId}-assets-${date}`);
const assetNames = [];
const assetFetcher = await Promise.allSettled(
selectedRows.map(async (row) => {
const asset = row?.original;
try {
assetNames.push(asset.displayName);
const res = await fetch(`${getApiBaseUrl()}/${asset.id}`);
if (!res.ok) {
throw new Error();
}
return res.blob();
} catch (error) {
downloadErrors.push(`Failed to download ${asset?.displayName}.`);
return null;
}
}),
);
const definedAssets = assetFetcher.filter(asset => asset.value !== null);
if (definedAssets.length > 0) {
definedAssets.forEach((assetBlob, index) => {
folder.file(assetNames[index], assetBlob.value, { blob: true });
});
zip.generateAsync({ type: 'blob' }).then(content => {
saveAs(content, `${courseId}-assets-${date}.zip`);
});
}
} else if (selectedRows?.length === 1) {
const asset = selectedRows[0].original;
try {
saveAs(`${getApiBaseUrl()}/${asset.id}`, asset.displayName);
} catch (error) {
downloadErrors.push(`Failed to download ${asset?.displayName}.`);
}
} else {
downloadErrors.push('No files were selected to download');
}
return downloadErrors;
}
/**
* Fetch where asset is used in a course.
* @param {blockId} courseId Course ID for the course to operate on
*/
export async function getAssetUsagePaths({ courseId, assetId }) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getAssetsUrl(courseId)}${assetId}/usage`);
@@ -27,7 +86,7 @@ export async function getAssetUsagePaths({ courseId, assetId }) {
}
/**
* Delete custom page for provided block.
* Delete asset to course.
* @param {blockId} courseId Course ID for the course to operate on
*/
@@ -37,7 +96,7 @@ export async function deleteAsset(courseId, assetId) {
}
/**
* Add custom page for provided block.
* Add asset to course.
* @param {blockId} courseId Course ID for the course to operate on
*/

View File

@@ -0,0 +1,57 @@
import { getDownload } from './api';
import 'file-saver';
jest.mock('file-saver');
describe('api.js', () => {
describe('getDownload', () => {
describe('selectedRows length is undefined or less than zero', () => {
it('should return with no files selected error if selectedRows is empty', async () => {
const expected = ['No files were selected to download'];
const actual = await getDownload([], 'courseId');
expect(actual).toEqual(expected);
});
it('should return with no files selected error if selectedRows is null', async () => {
const expected = ['No files were selected to download'];
const actual = await getDownload(null, 'courseId');
expect(actual).toEqual(expected);
});
});
describe('selectedRows length is greater than one', () => {
it('should not throw error when blob returns null', async () => {
const mockResponseData = { ok: true, blob: () => null };
const mockFetchResponse = Promise.resolve(mockResponseData);
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
const expected = [];
const actual = await getDownload([
{ original: { displayName: 'test1' } },
{ original: { displayName: 'test2', id: '2' } },
]);
expect(actual).toEqual(expected);
});
it('should return error if row does not contain .original ancestor', async () => {
const mockResponseData = { ok: true, blob: () => 'data' };
const mockFetchResponse = Promise.resolve(mockResponseData);
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
const expected = ['Failed to download undefined.'];
const actual = await getDownload([
{ asset: { displayName: 'test1', id: '1' } },
{ original: { displayName: 'test2', id: '2' } },
]);
expect(actual).toEqual(expected);
});
});
describe('selectedRows length equals one', () => {
it('should return error if row does not contain .original ancestor', async () => {
const mockResponseData = { ok: true, blob: () => 'data' };
const mockFetchResponse = Promise.resolve(mockResponseData);
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
const expected = ['Failed to download undefined.'];
const actual = await getDownload([
{ asset: { displayName: 'test1', id: '1' } },
]);
expect(actual).toEqual(expected);
});
});
});
});

View File

@@ -13,9 +13,10 @@ const slice = createSlice({
deletingStatus: '',
usageStatus: '',
errors: {
upload: [],
add: [],
delete: [],
lock: [],
download: [],
usageMetrics: [],
},
totalCount: 0,
@@ -30,14 +31,27 @@ const slice = createSlice({
updateLoadingStatus: (state, { payload }) => {
state.loadingStatus = payload.status;
},
updateUpdatingStatus: (state, { payload }) => {
state.updatingStatus = payload.status;
},
updateAddingStatus: (state, { payload }) => {
state.addingStatus = payload.status;
},
updateDeletingStatus: (state, { payload }) => {
state.deletingStatus = payload.status;
updateEditStatus: (state, { payload }) => {
const { editType, status } = payload;
switch (editType) {
case 'delete':
state.deletingStatus = status;
break;
case 'add':
state.addingStatus = status;
break;
case 'lock':
state.updatingStatus = status;
break;
case 'download':
state.updatingStatus = status;
break;
case 'usageMetrics':
state.usageStatus = status;
break;
default:
break;
}
},
deleteAssetSuccess: (state, { payload }) => {
state.assetIds = state.assetIds.filter(id => id !== payload.assetId);
@@ -45,14 +59,15 @@ const slice = createSlice({
addAssetSuccess: (state, { payload }) => {
state.assetIds = [payload.assetId, ...state.assetIds];
},
updateUsageStatus: (state, { payload }) => {
state.usageStatus = payload.status;
},
updateErrors: (state, { payload }) => {
const { error, message } = payload;
const currentErrorState = state.errors[error];
state.errors[error] = [...currentErrorState, message];
},
clearErrors: (state, { payload }) => {
const { error } = payload;
state.errors[error] = [];
},
},
});
@@ -60,13 +75,11 @@ export const {
setAssetIds,
setTotalCount,
updateLoadingStatus,
updateUpdatingStatus,
deleteAssetSuccess,
updateDeletingStatus,
addAssetSuccess,
updateAddingStatus,
updateUsageStatus,
updateErrors,
clearErrors,
updateEditStatus,
} = slice.actions;
export const {

View File

@@ -1,3 +1,4 @@
import { isEmpty } from 'lodash';
import { RequestStatus } from '../../data/constants';
import {
addModel,
@@ -11,18 +12,17 @@ import {
addAsset,
deleteAsset,
updateLockStatus,
getDownload,
} from './api';
import {
setAssetIds,
setTotalCount,
updateLoadingStatus,
updateUpdatingStatus,
deleteAssetSuccess,
updateDeletingStatus,
addAssetSuccess,
updateAddingStatus,
updateErrors,
updateUsageStatus,
clearErrors,
updateEditStatus,
} from './slice';
import { updateFileValues } from './utils';
@@ -61,24 +61,24 @@ export function updateAssetOrder(courseId, assetIds) {
export function deleteAssetFile(courseId, id, totalCount) {
return async (dispatch) => {
dispatch(updateDeletingStatus({ status: RequestStatus.IN_PROGRESS }));
dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.IN_PROGRESS }));
try {
await deleteAsset(courseId, id);
dispatch(deleteAssetSuccess({ assetId: id }));
dispatch(removeModel({ modelType: 'assets', id }));
dispatch(setTotalCount({ totalCount: totalCount - 1 }));
dispatch(updateDeletingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateErrors({ error: 'delete', message: `Failed to delete file id ${id}.` }));
dispatch(updateDeletingStatus({ status: RequestStatus.FAILED }));
dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.FAILED }));
}
};
}
export function addAssetFile(courseId, file, totalCount) {
return async (dispatch) => {
dispatch(updateAddingStatus({ status: RequestStatus.IN_PROGRESS }));
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS }));
try {
const { asset } = await addAsset(courseId, file);
@@ -91,22 +91,22 @@ export function addAssetFile(courseId, file, totalCount) {
assetId: asset.id,
}));
dispatch(setTotalCount({ totalCount: totalCount + 1 }));
dispatch(updateAddingStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL }));
} catch (error) {
if (error.response && error.response.status === 413) {
const message = error.response.data.error;
dispatch(updateErrors({ error: 'upload', message }));
dispatch(updateErrors({ error: 'add', message }));
} else {
dispatch(updateErrors({ error: 'upload', message: `Failed to add ${file.name}.` }));
dispatch(updateErrors({ error: 'add', message: `Failed to add ${file.name}.` }));
}
dispatch(updateAddingStatus({ status: RequestStatus.FAILED }));
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }));
}
};
}
export function updateAssetLock({ assetId, courseId, locked }) {
return async (dispatch) => {
dispatch(updateUpdatingStatus({ status: RequestStatus.IN_PROGRESS }));
dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.IN_PROGRESS }));
try {
await updateLockStatus({ assetId, courseId, locked });
@@ -117,26 +117,45 @@ export function updateAssetLock({ assetId, courseId, locked }) {
locked,
},
}));
dispatch(updateUpdatingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.SUCCESSFUL }));
} catch (error) {
const lockStatus = locked ? 'lock' : 'unlock';
dispatch(updateErrors({ error: 'lock', message: `Failed to ${lockStatus} file id ${assetId}.` }));
dispatch(updateUpdatingStatus({ status: RequestStatus.FAILED }));
dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.FAILED }));
}
};
}
export function resetErrors({ errorType }) {
return (dispatch) => { dispatch(clearErrors({ error: errorType })); };
}
export function getUsagePaths({ asset, courseId, setSelectedRows }) {
return async (dispatch) => {
dispatch(updateUsageStatus({ status: RequestStatus.IN_PROGRESS }));
dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.IN_PROGRESS }));
try {
const { usageLocations } = await getAssetUsagePaths({ assetId: asset.id, courseId });
setSelectedRows([{ original: { ...asset, usageLocations } }]);
dispatch(updateUsageStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateErrors({ error: 'usageMetrics', message: `Failed to get usage metrics for ${asset.displayName}.` }));
dispatch(updateUsageStatus({ status: RequestStatus.FAILED }));
dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.FAILED }));
}
};
}
export function fetchAssetDownload({ selectedRows, courseId }) {
return async (dispatch) => {
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.IN_PROGRESS }));
const errors = await getDownload(selectedRows, courseId);
if (isEmpty(errors)) {
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.SUCCESSFUL }));
} else {
errors.forEach(error => {
dispatch(updateErrors({ error: 'download', message: error }));
});
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.FAILED }));
}
};
}

View File

@@ -15,9 +15,10 @@ export const initialState = {
addingStatus: '',
usageStatus: '',
errors: {
upload: [],
add: [],
delete: [],
lock: [],
download: [],
usageMetrics: [],
},
},

View File

@@ -17,6 +17,7 @@ import { getSrc } from '../data/utils';
const GalleryCard = ({
className,
original,
handleBulkDownload,
handleLockedAsset,
handleOpenDeleteConfirmation,
handleOpenAssetInfo,
@@ -43,6 +44,9 @@ const GalleryCard = ({
portableUrl={original.portableUrl}
iconSrc={MoreVert}
id={original.id}
onDownload={() => handleBulkDownload(
[{ original: { id: original.id, displayName: original.displayName } }],
)}
openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])}
/>
</ActionRow>
@@ -87,6 +91,7 @@ GalleryCard.propTypes = {
id: PropTypes.string.isRequired,
portableUrl: PropTypes.string.isRequired,
}).isRequired,
handleBulkDownload: PropTypes.func.isRequired,
handleLockedAsset: PropTypes.func.isRequired,
handleOpenDeleteConfirmation: PropTypes.func.isRequired,
handleOpenAssetInfo: PropTypes.func.isRequired,

View File

@@ -17,6 +17,7 @@ import { getSrc } from '../data/utils';
const ListCard = ({
className,
original,
handleBulkDownload,
handleLockedAsset,
handleOpenDeleteConfirmation,
handleOpenAssetInfo,
@@ -67,6 +68,9 @@ const ListCard = ({
portableUrl={original.portableUrl}
iconSrc={MoreVert}
id={original.id}
onDownload={() => handleBulkDownload(
[{ original: { id: original.id, displayName: original.displayName } }],
)}
openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])}
/>
</ActionRow>
@@ -89,6 +93,7 @@ ListCard.propTypes = {
id: PropTypes.string.isRequired,
portableUrl: PropTypes.string.isRequired,
}).isRequired,
handleBulkDownload: PropTypes.func.isRequired,
handleLockedAsset: PropTypes.func.isRequired,
handleOpenDeleteConfirmation: PropTypes.func.isRequired,
handleOpenAssetInfo: PropTypes.func.isRequired,

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import CourseStepper from '.';
const stepsMock = [
{
title: 'Preparing',
description: 'Preparing to start the export',
},
{
title: 'Exporting',
description: 'Creating the export data files (You can now leave this page safely, but avoid making drastic changes to content until this export is complete',
},
{
title: 'Compressing',
description: 'Compressing the exported data and preparing it for download',
},
{
title: 'Success',
description: 'Your exported course can now be downloaded',
},
];
const renderComponent = (props) => render(
<IntlProvider locale="en">
<CourseStepper steps={stepsMock} {...props} />
</IntlProvider>,
);
describe('<CourseStepper />', () => {
it('renders CourseStepper correctly', () => {
const {
getByText, getByTestId, getAllByTestId, queryByTestId,
} = renderComponent({ activeKey: 0 });
const steps = getAllByTestId('course-stepper__step');
expect(steps.length).toBe(stepsMock.length);
stepsMock.forEach((step) => {
expect(getByText(step.title)).toBeInTheDocument();
expect(getByText(step.description)).toBeInTheDocument();
expect(getByTestId(`${step.title}-icon`)).toBeInTheDocument();
});
const percentElement = queryByTestId('course-stepper__step-percent');
expect(percentElement).toBeNull();
});
it('marks the active and done steps correctly', () => {
const activeKey = 1;
const { getAllByTestId } = renderComponent({ activeKey });
const steps = getAllByTestId('course-stepper__step');
stepsMock.forEach((_, index) => {
const stepElement = steps[index];
if (index === activeKey) {
expect(stepElement).toHaveClass('active');
expect(stepElement).not.toHaveClass('done');
}
if (index < activeKey) {
expect(stepElement).not.toHaveClass('active');
expect(stepElement).toHaveClass('done');
}
if (index > activeKey) {
expect(stepElement).not.toHaveClass('active');
expect(stepElement).not.toHaveClass('done');
}
});
});
it('mark the error step correctly', () => {
const { getAllByTestId } = renderComponent({ activeKey: 1, hasError: true });
const errorStep = getAllByTestId('course-stepper__step')[1];
expect(errorStep).toHaveClass('error');
});
it('shows error message for error step', () => {
const errorMessage = 'Some error text';
const { getAllByTestId } = renderComponent({ activeKey: 1, hasError: true, errorMessage });
const errorStep = getAllByTestId('course-stepper__step')[1];
expect(errorStep).toHaveClass('error');
});
it('shows percentage for active step', () => {
const percent = 50;
const { getByTestId } = renderComponent({ activeKey: 1, percent });
const percentElement = getByTestId('course-stepper__step-percent');
expect(percentElement).toBeInTheDocument();
expect(percentElement).toHaveTextContent(`${percent}%`);
});
it('shows null when steps length equal to zero', () => {
const { queryByTestId } = render(
<IntlProvider locale="en">
<CourseStepper steps={[]} activeKey={0} />
</IntlProvider>,
);
const steps = queryByTestId('[data-testid="course-stepper__step"]');
expect(steps).toBe(null);
});
});

View File

@@ -0,0 +1,68 @@
.course-stepper {
.course-stepper__step {
display: flex;
gap: $spacer;
padding: 1.25rem 0;
opacity: .5;
&:not(:last-child) {
border-bottom: 1px solid $gray-200;
}
.course-stepper__step-icon {
position: relative;
& svg {
position: absolute;
top: .875rem;
left: 1.25rem;
}
}
.course-stepper__step-info {
margin-left: 1.875rem;
}
.course-stepper__step-title {
margin-bottom: .25rem;
}
.course-stepper__step-percent {
margin: 0;
font-size: 1rem;
}
.course-stepper__step-description {
margin: 0;
font-size: 1rem;
color: $gray-400;
}
}
.course-stepper__step.active {
opacity: 1;
& svg {
animation: rotate 2s infinite linear;
}
}
.course-stepper__step.done {
opacity: 1;
& svg,
.course-stepper__step-title {
color: $success-500;
}
}
.course-stepper__step.error {
opacity: 1;
.course-stepper__step-title,
.course-stepper__step-description,
& svg {
color: $danger-300;
}
}
}

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { injectIntl } from '@edx/frontend-platform/i18n';
import {
Settings as SettingsIcon,
ManageHistory as SuccessIcon,
Warning as ErrorIcon,
CheckCircle,
} from '@edx/paragon/icons';
import { Icon } from '@edx/paragon';
import PropTypes from 'prop-types';
import classNames from 'classnames';
const CourseStepper = ({
steps,
activeKey,
percent,
hasError,
errorMessage,
}) => {
const getStepperSettings = (index) => {
const lastStepIndex = steps.length - 1;
const isActiveStep = index === activeKey;
const isLastStep = index === lastStepIndex;
const isErrorStep = isActiveStep && hasError;
const isLastStepDone = isLastStep && isActiveStep;
const completedStep = index < activeKey && !hasError;
const getStepIcon = () => {
if (completedStep) {
return CheckCircle;
}
if (hasError && isActiveStep) {
return ErrorIcon;
}
if (isLastStep && !isActiveStep) {
return SuccessIcon;
}
if (isLastStepDone) {
return CheckCircle;
}
return SettingsIcon;
};
return {
stepIcon: getStepIcon(index),
isPercentShow: Boolean(percent) && percent !== 100 && isActiveStep && !hasError,
isErrorMessageShow: isErrorStep && errorMessage,
isActiveClass: isActiveStep && !isLastStep && !hasError,
isDoneClass: index < activeKey || isLastStepDone,
isErrorClass: isErrorStep,
};
};
return (
<div className="course-stepper">
{steps.length ? steps.map(({ title, description }, index) => {
const {
stepIcon,
isPercentShow,
isErrorMessageShow,
isActiveClass,
isDoneClass,
isErrorClass,
} = getStepperSettings(index);
return (
<div
className={classNames('course-stepper__step', {
active: isActiveClass,
done: isDoneClass,
error: isErrorClass,
})}
key={title}
data-testid="course-stepper__step"
>
<div className="course-stepper__step-icon">
<Icon src={stepIcon} alt={title} data-testid={`${title}-icon`} />
</div>
<div className="course-stepper__step-info">
<h3 className="h4 title course-stepper__step-title font-weight-600">{title}</h3>
{isPercentShow && (
<p
className="course-stepper__step-percent font-weight-400"
data-testid="course-stepper__step-percent"
>
{percent}%
</p>
)}
<p className="course-stepper__step-description font-weight-400">
{isErrorMessageShow ? errorMessage : description}
</p>
</div>
</div>
);
}) : null}
</div>
);
};
CourseStepper.defaultProps = {
percent: false,
hasError: false,
errorMessage: '',
};
CourseStepper.propTypes = {
steps: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
})).isRequired,
activeKey: PropTypes.number.isRequired,
percent: PropTypes.oneOfType([PropTypes.number, PropTypes.bool]),
errorMessage: PropTypes.string,
hasError: PropTypes.bool,
};
export default injectIntl(CourseStepper);

View File

@@ -0,0 +1,296 @@
import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useParams } from 'react-router';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import {
Form,
Button,
Dropdown,
ActionRow,
StatefulButton,
TransitionReplace,
} from '@edx/paragon';
import { Info as InfoIcon } from '@edx/paragon/icons';
import { TypeaheadDropdown } from '@edx/frontend-lib-content-components';
import AlertMessage from '../alert-message';
import { STATEFUL_BUTTON_STATES } from '../../constants';
import { RequestStatus } from '../../data/constants';
import { getSavingStatus } from '../data/selectors';
import { getStudioHomeData } from '../../studio-home/data/selectors';
import { updatePostErrors } from '../data/slice';
import { updateCreateOrRerunCourseQuery } from '../data/thunks';
import { useCreateOrRerunCourse } from './hooks';
import messages from './messages';
const CreateOrRerunCourseForm = ({
title,
isCreateNewCourse,
initialValues,
onClickCancel,
}) => {
const { courseId } = useParams();
const savingStatus = useSelector(getSavingStatus);
const { allowToCreateNewOrg } = useSelector(getStudioHomeData);
const runFieldReference = useRef(null);
const displayNameFieldReference = useRef(null);
const {
intl,
errors,
values,
postErrors,
isFormFilled,
isFormInvalid,
organizations,
showErrorBanner,
dispatch,
handleBlur,
handleChange,
hasErrorField,
setFieldValue,
} = useCreateOrRerunCourse(initialValues);
const newCourseFields = [
{
label: intl.formatMessage(messages.courseDisplayNameLabel),
helpText: intl.formatMessage(
isCreateNewCourse
? messages.courseDisplayNameCreateHelpText
: messages.courseDisplayNameRerunHelpText,
),
name: 'displayName',
value: values.displayName,
placeholder: intl.formatMessage(messages.courseDisplayNamePlaceholder),
disabled: false,
ref: displayNameFieldReference,
},
{
label: intl.formatMessage(messages.courseOrgLabel),
helpText: isCreateNewCourse
? intl.formatMessage(messages.courseOrgCreateHelpText, {
strong: <strong>{intl.formatMessage(messages.courseNoteOrgNameIsPartStrong)}</strong>,
})
: intl.formatMessage(messages.courseOrgRerunHelpText, {
strong: (
<>
<br />
<strong>
{intl.formatMessage(messages.courseNoteNoSpaceAllowedStrong)}
</strong>
</>
),
}),
name: 'org',
value: values.org,
options: organizations,
placeholder: intl.formatMessage(messages.courseOrgPlaceholder),
disabled: false,
},
{
label: intl.formatMessage(messages.courseNumberLabel),
helpText: isCreateNewCourse
? intl.formatMessage(messages.courseNumberCreateHelpText, {
strong: (
<strong>
{intl.formatMessage(messages.courseNotePartCourseURLRequireStrong)}
</strong>
),
})
: intl.formatMessage(messages.courseNumberRerunHelpText),
name: 'number',
value: values.number,
placeholder: intl.formatMessage(messages.courseNumberPlaceholder),
disabled: !isCreateNewCourse,
},
{
label: intl.formatMessage(messages.courseRunLabel),
helpText: isCreateNewCourse
? intl.formatMessage(messages.courseRunCreateHelpText, {
strong: (
<strong>
{intl.formatMessage(messages.courseNotePartCourseURLRequireStrong)}
</strong>
),
})
: intl.formatMessage(messages.courseRunRerunHelpText, {
strong: (
<>
<br />
<strong>
{intl.formatMessage(messages.courseNoteNoSpaceAllowedStrong)}
</strong>
</>
),
}),
name: 'run',
value: values.run,
placeholder: intl.formatMessage(messages.courseRunPlaceholder),
disabled: false,
ref: runFieldReference,
},
];
const createButtonState = {
labels: {
default: intl.formatMessage(isCreateNewCourse ? messages.createButton : messages.rerunCreateButton),
pending: intl.formatMessage(isCreateNewCourse ? messages.creatingButton : messages.rerunningCreateButton),
},
disabledStates: [STATEFUL_BUTTON_STATES.pending],
};
const handleOnClickCreate = () => {
const courseData = isCreateNewCourse ? values : { ...values, sourceCourseKey: courseId };
dispatch(updateCreateOrRerunCourseQuery(courseData));
};
const handleOnClickCancel = () => {
dispatch(updatePostErrors({}));
onClickCancel();
};
const handleCustomBlurForDropdown = (e) => {
// it needs to correct handleOnChange Form.Autosuggest
const { value, name } = e.target;
setFieldValue(name, value);
handleBlur(e);
};
const renderOrgField = (field) => (allowToCreateNewOrg ? (
<TypeaheadDropdown
readOnly={false}
name={field.name}
value={field.value}
controlClassName={classNames({ 'is-invalid': hasErrorField(field.name) })}
options={field.options}
placeholder={field.placeholder}
handleBlur={handleCustomBlurForDropdown}
handleChange={(value) => setFieldValue(field.name, value)}
noOptionsMessage={intl.formatMessage(messages.courseOrgNoOptions)}
helpMessage=""
errorMessage=""
floatingLabel=""
/>
) : (
<Dropdown className="mr-2">
<Dropdown.Toggle id={`${field.name}-dropdown`} variant="outline-primary">
{field.value || intl.formatMessage(messages.courseOrgNoOptions)}
</Dropdown.Toggle>
<Dropdown.Menu>
{field.options?.map((value) => (
<Dropdown.Item
key={value}
onClick={() => setFieldValue(field.name, value)}
>
{value}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
));
useEffect(() => {
// it needs to display the initial focus for the field depending on the current page
if (!isCreateNewCourse) {
runFieldReference?.current?.focus();
} else {
displayNameFieldReference?.current?.focus();
}
}, []);
return (
<div className="create-or-rerun-course-form">
<TransitionReplace>
{showErrorBanner ? (
<AlertMessage
variant="danger"
icon={InfoIcon}
title={postErrors.errMsg}
aria-hidden="true"
aria-labelledby={intl.formatMessage(
messages.alertErrorExistsAriaLabelledBy,
)}
aria-describedby={intl.formatMessage(
messages.alertErrorExistsAriaDescribedBy,
)}
/>
) : null}
</TransitionReplace>
<h3 className="mb-3">{title}</h3>
<Form>
{newCourseFields.map((field) => (
<Form.Group
className={classNames('form-group-custom', {
'form-group-custom_isInvalid': hasErrorField(field.name),
})}
key={field.label}
>
<Form.Label>{field.label}</Form.Label>
{field.name !== 'org' ? (
<Form.Control
value={field.value}
placeholder={field.placeholder}
name={field.name}
onChange={handleChange}
onBlur={handleBlur}
isInvalid={hasErrorField(field.name)}
disabled={field.disabled}
ref={field?.ref}
/>
) : renderOrgField(field)}
<Form.Text>{field.helpText}</Form.Text>
{hasErrorField(field.name) && (
<Form.Control.Feedback
className="feedback-error"
type="invalid"
hasIcon={false}
>
{errors[field.name]}
</Form.Control.Feedback>
)}
</Form.Group>
))}
<ActionRow className="justify-content-start">
<Button
variant="outline-primary"
onClick={handleOnClickCancel}
>
{intl.formatMessage(messages.cancelButton)}
</Button>
<StatefulButton
key="save-button"
className="ml-3"
onClick={handleOnClickCreate}
disabled={!isFormFilled || isFormInvalid}
state={
savingStatus === RequestStatus.PENDING
? STATEFUL_BUTTON_STATES.pending
: STATEFUL_BUTTON_STATES.default
}
{...createButtonState}
/>
</ActionRow>
</Form>
</div>
);
};
CreateOrRerunCourseForm.defaultProps = {
title: '',
isCreateNewCourse: false,
};
CreateOrRerunCourseForm.propTypes = {
title: PropTypes.string,
initialValues: PropTypes.shape({
displayName: PropTypes.string.isRequired,
org: PropTypes.string.isRequired,
number: PropTypes.string.isRequired,
run: PropTypes.string.isRequired,
}).isRequired,
isCreateNewCourse: PropTypes.bool,
onClickCancel: PropTypes.func.isRequired,
};
export default CreateOrRerunCourseForm;

View File

@@ -0,0 +1,18 @@
.create-or-rerun-course-form {
.form-group-custom {
&:not(:last-child) {
margin-bottom: $spacer;
}
.pgn__form-label {
font: normal 1.125rem/1.75rem $font-family-base;
color: $gray-700;
margin-bottom: .25rem;
}
.pgn__form-control-description,
.pgn__form-text {
margin-top: .62rem;
}
}
}

View File

@@ -0,0 +1,272 @@
import React from 'react';
import {
act,
fireEvent,
screen,
render,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ReactDOM from 'react-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import MockAdapter from 'axios-mock-adapter';
import { studioHomeMock } from '../../studio-home/__mocks__';
import { getStudioHomeApiUrl } from '../../studio-home/data/api';
import { fetchStudioHomeData } from '../../studio-home/data/thunks';
import { RequestStatus } from '../../data/constants';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import { updateCreateOrRerunCourseQuery } from '../data/thunks';
import { getCreateOrRerunCourseUrl } from '../data/api';
import messages from './messages';
import { CreateOrRerunCourseForm } from '.';
import { initialState } from './factories/mockApiResponses';
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useParams: () => ({
courseId: 'course-id-mock',
}),
}));
let axiosMock;
let store;
ReactDOM.createPortal = jest.fn(node => node);
const onClickCancelMock = jest.fn();
const RootWrapper = (props) => (
<IntlProvider locale="en">
<AppProvider store={store}>
<CreateOrRerunCourseForm {...props} />
</AppProvider>
</IntlProvider>
);
const props = {
title: 'Mocked title',
isCreateNewCourse: true,
initialValues: {
displayName: '',
org: '',
number: '',
run: '',
},
onClickCancel: onClickCancelMock,
};
const mockStore = async () => {
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
await executeThunk(fetchStudioHomeData, store.dispatch);
};
describe('<CreateOrRerunCourseForm />', () => {
afterEach(() => jest.clearAllMocks());
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('renders form successfully', async () => {
render(<RootWrapper {...props} />);
await mockStore();
expect(screen.getByText(props.title)).toBeInTheDocument();
expect(screen.getByText(messages.courseDisplayNameLabel.defaultMessage)).toBeInTheDocument();
expect(screen.getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.courseOrgLabel.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.courseOrgNoOptions.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.courseNumberLabel.defaultMessage)).toBeInTheDocument();
expect(screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.courseRunLabel.defaultMessage)).toBeInTheDocument();
expect(screen.getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage)).toBeInTheDocument();
});
it('renders create course form with help text successfully', async () => {
render(<RootWrapper {...props} />);
await mockStore();
expect(screen.getByText(messages.courseDisplayNameCreateHelpText.defaultMessage)).toBeInTheDocument();
expect(screen.getByText('The name of the organization sponsoring the course.', { exact: false })).toBeInTheDocument();
expect(screen.getByText('The unique number that identifies your course within your organization.', { exact: false })).toBeInTheDocument();
expect(screen.getByText('The term in which your course will run.', { exact: false })).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.createButton.defaultMessage })).toBeInTheDocument();
});
it('renders rerun course form with help text successfully', async () => {
const initialProps = { ...props, isCreateNewCourse: false };
render(<RootWrapper {...initialProps} />);
await mockStore();
expect(screen.getByText(messages.courseDisplayNameRerunHelpText.defaultMessage)).toBeInTheDocument();
expect(screen.getByText('The name of the organization sponsoring the new course. (This name is often the same as the original organization name.)', { exact: false })).toBeInTheDocument();
expect(screen.getByText(messages.courseNumberRerunHelpText.defaultMessage)).toBeInTheDocument();
expect(screen.getByText('The term in which the new course will run. (This value is often different than the original course run value.)', { exact: false })).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.rerunCreateButton.defaultMessage })).toBeInTheDocument();
});
it('should call handleOnClickCancel if button cancel clicked', async () => {
render(<RootWrapper {...props} />);
await mockStore();
const cancelBtn = screen.getByRole('button', { name: messages.cancelButton.defaultMessage });
await act(async () => {
fireEvent.click(cancelBtn);
});
expect(onClickCancelMock).toHaveBeenCalled();
});
describe('handleOnClickCreate', () => {
delete window.location;
window.location = { assign: jest.fn() };
it('should call window.location.assign with url', async () => {
render(<RootWrapper {...props} />);
await mockStore();
const url = '/course/courseId';
const displayNameInput = screen.getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage);
const orgInput = screen.getByText(messages.courseOrgNoOptions.defaultMessage);
const numberInput = screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
const runInput = screen.getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage);
const createBtn = screen.getByRole('button', { name: messages.createButton.defaultMessage });
await act(async () => {
userEvent.type(displayNameInput, 'foo course name');
fireEvent.click(orgInput);
userEvent.type(numberInput, '777');
userEvent.type(runInput, '1');
userEvent.click(createBtn);
});
await axiosMock.onPost(getCreateOrRerunCourseUrl()).reply(200, { url });
await executeThunk(updateCreateOrRerunCourseQuery({ org: 'testX', run: 'some' }), store.dispatch);
expect(window.location.assign).toHaveBeenCalledWith(`${process.env.STUDIO_BASE_URL}${url}`);
});
it('should call window.location.assign with url and destinationCourseKey', async () => {
render(<RootWrapper {...props} />);
await mockStore();
const url = '/course/';
const destinationCourseKey = 'courseKey';
const displayNameInput = screen.getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage);
const orgInput = screen.getByText(messages.courseOrgNoOptions.defaultMessage);
const numberInput = screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
const runInput = screen.getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage);
const createBtn = screen.getByRole('button', { name: messages.createButton.defaultMessage });
await axiosMock.onPost(getCreateOrRerunCourseUrl()).reply(200, { url, destinationCourseKey });
await act(async () => {
userEvent.type(displayNameInput, 'foo course name');
fireEvent.click(orgInput);
userEvent.type(numberInput, '777');
userEvent.type(runInput, '1');
userEvent.click(createBtn);
});
await executeThunk(updateCreateOrRerunCourseQuery({ org: 'testX', run: 'some' }), store.dispatch);
expect(window.location.assign).toHaveBeenCalledWith(`${process.env.STUDIO_BASE_URL}${url}${destinationCourseKey}`);
});
});
it('should be disabled create button if form not filled', async () => {
render(<RootWrapper {...props} />);
await mockStore();
const createBtn = screen.getByRole('button', { name: messages.createButton.defaultMessage });
expect(createBtn).toBeDisabled();
});
it('should be disabled rerun button if form not filled', async () => {
const initialProps = { ...props, isCreateNewCourse: false };
render(<RootWrapper {...initialProps} />);
await mockStore();
const rerunBtn = screen.getByRole('button', { name: messages.rerunCreateButton.defaultMessage });
expect(rerunBtn).toBeDisabled();
});
it('should be disabled create button if form has error', async () => {
render(<RootWrapper {...props} />);
await mockStore();
const createBtn = screen.getByRole('button', { name: messages.createButton.defaultMessage });
const displayNameInput = screen.getByPlaceholderText(messages.courseDisplayNamePlaceholder.defaultMessage);
const orgInput = screen.getByText(messages.courseOrgNoOptions.defaultMessage);
const numberInput = screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
const runInput = screen.getByPlaceholderText(messages.courseRunPlaceholder.defaultMessage);
await act(async () => {
fireEvent.change(displayNameInput, { target: { value: 'foo course name' } });
fireEvent.click(orgInput);
fireEvent.change(numberInput, { target: { value: 'number with invalid (+) symbol' } });
fireEvent.change(runInput, { target: { value: 'number with invalid (=) symbol' } });
});
waitFor(() => {
expect(createBtn).toBeDisabled();
});
});
it('shows typeahead dropdown with allowed to create org permissions', async () => {
const updatedStudioData = { ...studioHomeMock, allowToCreateNewOrg: true };
store = initializeStore({
...initialState,
studioHome: {
...initialState.studioHome,
studioHomeData: updatedStudioData,
},
});
render(<RootWrapper {...props} />);
await mockStore();
expect(screen.getByPlaceholderText(messages.courseOrgPlaceholder.defaultMessage));
});
it('shows button pending state', async () => {
store = initializeStore({
...initialState,
generic: {
...initialState.generic,
savingStatus: RequestStatus.PENDING,
},
});
render(<RootWrapper {...props} />);
await mockStore();
expect(screen.getByRole('button', { name: messages.creatingButton.defaultMessage })).toBeInTheDocument();
});
it('shows alert error if postErrors presents', async () => {
render(<RootWrapper {...props} />);
await mockStore();
await axiosMock.onPost(getCreateOrRerunCourseUrl()).reply(200, { errMsg: 'aaa' });
await executeThunk(updateCreateOrRerunCourseQuery({ org: 'testX', run: 'some' }), store.dispatch);
expect(screen.getByText('aaa')).toBeInTheDocument();
});
it('shows error on field', async () => {
render(<RootWrapper {...props} />);
await mockStore();
const numberInput = screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
await act(async () => {
fireEvent.change(numberInput, { target: { value: 'number with invalid (+) symbol' } });
});
waitFor(() => {
expect(screen.getByText(messages.noSpaceError)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,31 @@
import { RequestStatus } from '../../../data/constants';
import { studioHomeMock } from '../../../studio-home/__mocks__';
export const courseId = 'course-v1:edX+DemoX+Demo_Course';
export const initialState = {
generic: {
createOrRerunCourse: {
courseData: {},
courseRerunData: {},
redirectUrlObj: {},
postErrors: {},
},
loadingStatuses: {
organizationLoadingStatus: 'successful', courseRerunLoadingStatus: 'successful',
},
organizations: ['krisEdx', 'krisEd', 'DeveloperInc', 'importMit', 'testX', 'edX', 'developerInb'],
savingStatus: '',
},
studioHome: {
loadingStatuses: {
studioHomeLoadingStatus: RequestStatus.SUCCESSFUL,
courseNotificationLoadingStatus: RequestStatus.IN_PROGRESS,
},
savingStatuses: {
courseCreatorSavingStatus: '',
deleteNotificationSavingStatus: '',
},
studioHomeData: studioHomeMock,
},
};

View File

@@ -0,0 +1,128 @@
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { RequestStatus } from '../../data/constants';
import { getStudioHomeData } from '../../studio-home/data/selectors';
import {
getRedirectUrlObj,
getOrganizations,
getPostErrors,
getSavingStatus,
} from '../data/selectors';
import { updateSavingStatus, updatePostErrors } from '../data/slice';
import { fetchOrganizationsQuery } from '../data/thunks';
import messages from './messages';
const useCreateOrRerunCourse = (initialValues) => {
const intl = useIntl();
const dispatch = useDispatch();
const redirectUrlObj = useSelector(getRedirectUrlObj);
const createOrRerunCourseSavingStatus = useSelector(getSavingStatus);
const allOrganizations = useSelector(getOrganizations);
const postErrors = useSelector(getPostErrors);
const {
allowToCreateNewOrg,
allowedOrganizations,
} = useSelector(getStudioHomeData);
const [isFormFilled, setFormFilled] = useState(false);
const [showErrorBanner, setShowErrorBanner] = useState(false);
const organizations = allowToCreateNewOrg ? allOrganizations : allowedOrganizations;
const specialCharsRule = /^[a-zA-Z0-9_\-.'*~\s]+$/;
const noSpaceRule = /^\S*$/;
const validationSchema = Yup.object().shape({
displayName: Yup.string().required(
intl.formatMessage(messages.requiredFieldError),
),
org: Yup.string()
.required(intl.formatMessage(messages.requiredFieldError))
.matches(
specialCharsRule,
intl.formatMessage(messages.disallowedCharsError),
)
.matches(noSpaceRule, intl.formatMessage(messages.noSpaceError)),
number: Yup.string()
.required(intl.formatMessage(messages.requiredFieldError))
.matches(
specialCharsRule,
intl.formatMessage(messages.disallowedCharsError),
)
.matches(noSpaceRule, intl.formatMessage(messages.noSpaceError)),
run: Yup.string()
.required(intl.formatMessage(messages.requiredFieldError))
.matches(
specialCharsRule,
intl.formatMessage(messages.disallowedCharsError),
)
.matches(noSpaceRule, intl.formatMessage(messages.noSpaceError)),
});
const {
values, errors, touched, handleChange, handleBlur, setFieldValue,
} = useFormik({
initialValues,
enableReinitialize: true,
validateOnBlur: false,
validationSchema,
});
useEffect(() => {
if (allowToCreateNewOrg) {
dispatch(fetchOrganizationsQuery());
}
}, []);
useEffect(() => {
setFormFilled(Object.values(values).every((i) => i));
dispatch(updatePostErrors({}));
}, [values]);
useEffect(() => {
setShowErrorBanner(!!postErrors.errMsg);
}, [postErrors]);
useEffect(() => {
if (createOrRerunCourseSavingStatus === RequestStatus.SUCCESSFUL) {
dispatch(updateSavingStatus({ status: '' }));
const { url, destinationCourseKey } = redirectUrlObj;
// New courses' url to the outline page is provided in the url. However, for course
// re-runs the url is /course/. The actual destination for the rer-run's outline
// is in the destionationCourseKey attribute from the api.
if (url) {
if (destinationCourseKey) {
window.location.assign(`${getConfig().STUDIO_BASE_URL}${url}${destinationCourseKey}`);
} else {
window.location.assign(`${getConfig().STUDIO_BASE_URL}${url}`);
}
}
} else if (createOrRerunCourseSavingStatus === RequestStatus.FAILED) {
dispatch(updateSavingStatus({ status: '' }));
}
}, [createOrRerunCourseSavingStatus]);
const hasErrorField = (fieldName) => !!errors[fieldName] && !!touched[fieldName];
const isFormInvalid = Object.keys(errors).length;
return {
intl,
errors,
values,
postErrors,
isFormFilled,
isFormInvalid,
organizations,
showErrorBanner,
dispatch,
handleBlur,
handleChange,
hasErrorField,
setFieldValue,
setShowErrorBanner,
};
};
// eslint-disable-next-line import/prefer-default-export
export { useCreateOrRerunCourse };

View File

@@ -0,0 +1,2 @@
export { default as CreateOrRerunCourseForm } from './CreateOrRerunCourseForm';
export { useCreateOrRerunCourse } from './hooks';

View File

@@ -0,0 +1,130 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
courseDisplayNameLabel: {
id: 'course-authoring.create-or-rerun-course.display-name.label',
defaultMessage: 'Course name',
},
courseDisplayNamePlaceholder: {
id: 'course-authoring.create-or-rerun-course.display-name.placeholder',
defaultMessage: 'e.g. Introduction to Computer Science',
},
courseDisplayNameCreateHelpText: {
id: 'course-authoring.create-or-rerun-course.create.display-name.help-text',
defaultMessage: 'The public display name for your course. This cannot be changed, but you can set a different display name in advanced settings later.',
},
courseDisplayNameRerunHelpText: {
id: 'course-authoring.create-or-rerun-course.rerun.display-name.help-text',
defaultMessage: 'The public display name for the new course. (This name is often the same as the original course name.)',
},
courseOrgLabel: {
id: 'course-authoring.create-or-rerun-course.org.label',
defaultMessage: 'Organization',
},
courseOrgPlaceholder: {
id: 'course-authoring.create-or-rerun-course.org.placeholder',
defaultMessage: 'e.g. UniversityX or OrganizationX',
},
courseOrgNoOptions: {
id: 'course-authoring.create-or-rerun-course.org.no-options',
defaultMessage: 'No options',
},
courseOrgCreateHelpText: {
id: 'course-authoring.create-or-rerun-course.create.org.help-text',
defaultMessage: 'The name of the organization sponsoring the course. {strong} This cannot be changed, but you can set a different display name in advanced settings later.',
},
courseOrgRerunHelpText: {
id: 'course-authoring.create-or-rerun-course.rerun.org.help-text',
defaultMessage: 'The name of the organization sponsoring the new course. (This name is often the same as the original organization name.) {strong}',
},
courseNoteNoSpaceAllowedStrong: {
id: 'course-authoring.create-or-rerun-course.no-space-allowed.strong',
defaultMessage: 'Note: No spaces or special characters are allowed.',
},
courseNoteOrgNameIsPartStrong: {
id: 'course-authoring.create-or-rerun-course.org.help-text.strong',
defaultMessage: 'Note: The organization name is part of the course URL.',
},
courseNumberLabel: {
id: 'course-authoring.create-or-rerun-course.number.label',
defaultMessage: 'Course number',
},
courseNumberPlaceholder: {
id: 'course-authoring.create-or-rerun-course.number.placeholder',
defaultMessage: 'e.g. CS101',
},
courseNumberCreateHelpText: {
id: 'course-authoring.create-or-rerun-course.create.number.help-text',
defaultMessage: 'The unique number that identifies your course within your organization. {strong}',
},
courseNumberRerunHelpText: {
id: 'course-authoring.create-or-rerun-course.rerun.number.help-text',
defaultMessage: 'The unique number that identifies the new course within the organization. (This number will be the same as the original course number and cannot be changed.)',
},
courseNotePartCourseURLRequireStrong: {
id: 'course-authoring.create-or-rerun-course.number.help-text.strong',
defaultMessage: 'Note: This is part of your course URL, so no spaces or special characters are allowed and it cannot be changed.',
},
courseRunLabel: {
id: 'course-authoring.create-or-rerun-course.run.label',
defaultMessage: 'Course run',
},
courseRunPlaceholder: {
id: 'course-authoring.create-or-rerun-course.run.placeholder',
defaultMessage: 'e.g. 2014_T1',
},
courseRunCreateHelpText: {
id: 'course-authoring.create-or-rerun-course.create.run.help-text',
defaultMessage: 'The term in which your course will run. {strong}',
},
courseRunRerunHelpText: {
id: 'course-authoring.create-or-rerun-course.create.rerun.help-text',
defaultMessage: 'The term in which the new course will run. (This value is often different than the original course run value.){strong}',
},
defaultPlaceholder: {
id: 'course-authoring.create-or-rerun-course.default-placeholder',
defaultMessage: 'Label',
},
createButton: {
id: 'course-authoring.create-or-rerun-course.create.button.create',
defaultMessage: 'Create',
},
rerunCreateButton: {
id: 'course-authoring.create-or-rerun-course.rerun.button.create',
defaultMessage: 'Create re-run',
},
creatingButton: {
id: 'course-authoring.create-or-rerun-course.button.creating',
defaultMessage: 'Creating',
},
rerunningCreateButton: {
id: 'course-authoring.create-or-rerun-course.rerun.button.rerunning',
defaultMessage: 'Processing re-run request',
},
cancelButton: {
id: 'course-authoring.create-or-rerun-course.button.cancel',
defaultMessage: 'Cancel',
},
requiredFieldError: {
id: 'course-authoring.create-or-rerun-course.required.error',
defaultMessage: 'Required field.',
},
disallowedCharsError: {
id: 'course-authoring.create-or-rerun-course.disallowed-chars.error',
defaultMessage: 'Please do not use any spaces or special characters in this field.',
},
noSpaceError: {
id: 'course-authoring.create-or-rerun-course.no-space.error',
defaultMessage: 'Please do not use any spaces in this field.',
},
alertErrorExistsAriaLabelledBy: {
id: 'course-authoring.create-or-rerun-course.error.already-exists.labelledBy',
defaultMessage: 'alert-already-exists-title',
},
alertErrorExistsAriaDescribedBy: {
id: 'course-authoring.create-or-rerun-course.error.already-exists.aria.describedBy',
defaultMessage: 'alert-confirmation-description',
},
});
export default messages;

44
src/generic/data/api.js Normal file
View File

@@ -0,0 +1,44 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { convertObjectToSnakeCase } from '../../utils';
export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCreateOrRerunCourseUrl = () => new URL('course/', getApiBaseUrl()).href;
export const getCourseRerunUrl = (courseId) => new URL(`/api/contentstore/v1/course_rerun/${courseId}`, getApiBaseUrl()).href;
export const getOrganizationsUrl = () => new URL('organizations', getApiBaseUrl()).href;
/**
* Get's organizations data.
* @returns {Promise<Object>}
*/
export async function getOrganizations() {
const { data } = await getAuthenticatedHttpClient().get(
getOrganizationsUrl(),
);
return camelCaseObject(data);
}
/**
* Get's course rerun data.
* @returns {Promise<Object>}
*/
export async function getCourseRerun(courseId) {
const { data } = await getAuthenticatedHttpClient().get(
getCourseRerunUrl(courseId),
);
return camelCaseObject(data);
}
/**
* Create or rerun course with data.
* @param {object} data
* @returns {Promise<Object>}
*/
export async function createOrRerunCourse(courseData) {
const { data } = await getAuthenticatedHttpClient().post(
getCreateOrRerunCourseUrl(),
convertObjectToSnakeCase(courseData, true),
);
return camelCaseObject(data);
}

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