Compare commits
30 Commits
refactor--
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14f035435c | ||
|
|
fa25150e3d | ||
|
|
3fe35344f0 | ||
|
|
bbca5a29b7 | ||
|
|
2a6a816baf | ||
|
|
73f7d5d5f5 | ||
|
|
0871ce345a | ||
|
|
01ddac380f | ||
|
|
4840666664 | ||
|
|
21e4ece669 | ||
|
|
887a628c23 | ||
|
|
2ea876ae4f | ||
|
|
c47c800cfa | ||
|
|
ef9633af35 | ||
|
|
217b86e616 | ||
|
|
37aabc4948 | ||
|
|
e099243437 | ||
|
|
6f238bdbe0 | ||
|
|
77dfd0296c | ||
|
|
1888993113 | ||
|
|
fb28693854 | ||
|
|
7f8c6f2d61 | ||
|
|
15984473b4 | ||
|
|
b03ecf1562 | ||
|
|
fdc5916ada | ||
|
|
a54d351e9c | ||
|
|
62cde57556 | ||
|
|
2bd8037d7b | ||
|
|
a1793efcc0 | ||
|
|
ed2eed5110 |
6
.env
6
.env
@@ -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=''
|
||||
|
||||
@@ -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=''
|
||||
|
||||
@@ -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=''
|
||||
|
||||
@@ -8,3 +8,5 @@ coverage:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 0%
|
||||
ignore:
|
||||
- "src/grading-settings/grading-scale/react-ranger.js"
|
||||
|
||||
1100
package-lock.json
generated
1100
package-lock.json
generated
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"rebaseStalePrs": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["@edx"],
|
||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`]}>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
$text-color-base: $gray-700;
|
||||
$setting-form-control-width: 34.375rem;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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> </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',
|
||||
};
|
||||
|
||||
93
src/course-rerun/CourseRerun.test.jsx
Normal file
93
src/course-rerun/CourseRerun.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
58
src/course-rerun/course-rerun-form/CourseRerunForm.test.jsx
Normal file
58
src/course-rerun/course-rerun-form/CourseRerunForm.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
36
src/course-rerun/course-rerun-form/index.jsx
Normal file
36
src/course-rerun/course-rerun-form/index.jsx
Normal 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;
|
||||
14
src/course-rerun/course-rerun-form/messages.js
Normal file
14
src/course-rerun/course-rerun-form/messages.js
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
83
src/course-rerun/course-rerun-sidebar/index.jsx
Normal file
83
src/course-rerun/course-rerun-sidebar/index.jsx
Normal 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;
|
||||
34
src/course-rerun/course-rerun-sidebar/messages.js
Normal file
34
src/course-rerun/course-rerun-sidebar/messages.js
Normal 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;
|
||||
67
src/course-rerun/hooks.jsx
Normal file
67
src/course-rerun/hooks.jsx
Normal 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 };
|
||||
99
src/course-rerun/index.jsx
Normal file
99
src/course-rerun/index.jsx
Normal 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;
|
||||
14
src/course-rerun/messages.js
Normal file
14
src/course-rerun/messages.js
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -45,7 +45,6 @@ const InfoModal = ({
|
||||
</Button>
|
||||
{modalType === MODAL_TYPES.delete && (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onDeleteSubmit();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -32,10 +32,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .25rem;
|
||||
|
||||
svg {
|
||||
color: $warning-300;
|
||||
}
|
||||
}
|
||||
|
||||
.react-datepicker-popper {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -11,6 +11,7 @@ export const RequestStatus = {
|
||||
FAILED: 'failed',
|
||||
DENIED: 'denied',
|
||||
PENDING: 'pending',
|
||||
CLEAR: 'clear',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
127
src/export-page/CourseExportPage.jsx
Normal file
127
src/export-page/CourseExportPage.jsx
Normal 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);
|
||||
8
src/export-page/CourseExportPage.scss
Normal file
8
src/export-page/CourseExportPage.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
@import "./export-stepper/ExportStepper";
|
||||
@import "./export-footer/ExportFooter";
|
||||
|
||||
.export {
|
||||
.help-sidebar {
|
||||
margin-top: 7.188rem;
|
||||
}
|
||||
}
|
||||
140
src/export-page/CourseExportPage.test.jsx
Normal file
140
src/export-page/CourseExportPage.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
3
src/export-page/__mocks__/exportPage.js
Normal file
3
src/export-page/__mocks__/exportPage.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
exportStatus: 1,
|
||||
};
|
||||
2
src/export-page/__mocks__/index.js
Normal file
2
src/export-page/__mocks__/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as exportPageMock } from './exportPage';
|
||||
19
src/export-page/data/api.js
Normal file
19
src/export-page/data/api.js
Normal 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);
|
||||
}
|
||||
47
src/export-page/data/api.test.js
Normal file
47
src/export-page/data/api.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
8
src/export-page/data/constants.js
Normal file
8
src/export-page/data/constants.js
Normal 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';
|
||||
8
src/export-page/data/selectors.js
Normal file
8
src/export-page/data/selectors.js
Normal 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;
|
||||
63
src/export-page/data/slice.js
Normal file
63
src/export-page/data/slice.js
Normal 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;
|
||||
80
src/export-page/data/thunks.js
Normal file
80
src/export-page/data/thunks.js
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
49
src/export-page/export-footer/ExportFooter.jsx
Normal file
49
src/export-page/export-footer/ExportFooter.jsx
Normal 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);
|
||||
14
src/export-page/export-footer/ExportFooter.scss
Normal file
14
src/export-page/export-footer/ExportFooter.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
58
src/export-page/export-footer/messages.js
Normal file
58
src/export-page/export-footer/messages.js
Normal 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;
|
||||
53
src/export-page/export-modal-error/ExportModalError.jsx
Normal file
53
src/export-page/export-modal-error/ExportModalError.jsx
Normal 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);
|
||||
34
src/export-page/export-modal-error/messages.js
Normal file
34
src/export-page/export-modal-error/messages.js
Normal 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;
|
||||
49
src/export-page/export-sidebar/ExportSidebar.jsx
Normal file
49
src/export-page/export-sidebar/ExportSidebar.jsx
Normal 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);
|
||||
39
src/export-page/export-sidebar/ExportSidebar.test.jsx
Normal file
39
src/export-page/export-sidebar/ExportSidebar.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
66
src/export-page/export-sidebar/messages.js
Normal file
66
src/export-page/export-sidebar/messages.js
Normal 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;
|
||||
103
src/export-page/export-stepper/ExportStepper.jsx
Normal file
103
src/export-page/export-stepper/ExportStepper.jsx
Normal 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);
|
||||
3
src/export-page/export-stepper/ExportStepper.scss
Normal file
3
src/export-page/export-stepper/ExportStepper.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.pgn__stepper-header-step-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
38
src/export-page/export-stepper/ExportStepper.test.jsx
Normal file
38
src/export-page/export-stepper/ExportStepper.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
46
src/export-page/export-stepper/messages.js
Normal file
46
src/export-page/export-stepper/messages.js
Normal 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;
|
||||
34
src/export-page/messages.js
Normal file
34
src/export-page/messages.js
Normal 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
29
src/export-page/utils.js
Normal 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;
|
||||
};
|
||||
51
src/export-page/utils.test.js
Normal file
51
src/export-page/utils.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
*/
|
||||
|
||||
57
src/files-and-uploads/data/api.test.js
Normal file
57
src/files-and-uploads/data/api.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@ export const initialState = {
|
||||
addingStatus: '',
|
||||
usageStatus: '',
|
||||
errors: {
|
||||
upload: [],
|
||||
add: [],
|
||||
delete: [],
|
||||
lock: [],
|
||||
download: [],
|
||||
usageMetrics: [],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
107
src/generic/course-stepper/CourseStepper.test.jsx
Normal file
107
src/generic/course-stepper/CourseStepper.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
68
src/generic/course-stepper/CouseStepper.scss
Normal file
68
src/generic/course-stepper/CouseStepper.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
118
src/generic/course-stepper/index.jsx
Normal file
118
src/generic/course-stepper/index.jsx
Normal 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);
|
||||
296
src/generic/create-or-rerun-course/CreateOrRerunCourseForm.jsx
Normal file
296
src/generic/create-or-rerun-course/CreateOrRerunCourseForm.jsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
128
src/generic/create-or-rerun-course/hooks.jsx
Normal file
128
src/generic/create-or-rerun-course/hooks.jsx
Normal 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 };
|
||||
2
src/generic/create-or-rerun-course/index.js
Normal file
2
src/generic/create-or-rerun-course/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as CreateOrRerunCourseForm } from './CreateOrRerunCourseForm';
|
||||
export { useCreateOrRerunCourse } from './hooks';
|
||||
130
src/generic/create-or-rerun-course/messages.js
Normal file
130
src/generic/create-or-rerun-course/messages.js
Normal 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
44
src/generic/data/api.js
Normal 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
Reference in New Issue
Block a user