Refactoring and organization (#41)

* Updating dependencies and removing unneeded ones.

* Fixing broken IntlProvider attribute in ProctoredExamSettings test.

* package-lock.json was out of sync - checking it in.

* Initializing an empty redux store.

* Adding model-store from frontend-app-learning.

This will let us save data from the server in a normalized way in redux, reducing boilerplate in React components.

* Fixing paragon button usage.

(also just organizing the imports while I was there…)

* Using paragon button instead of an anchor tag.

For the “New Page” button in the pages & resources view.

* Add API, reducers, and thunks to add course detail data into redux.

Subsequent PR will use this to store course detail data for use across different pages in the application.

* Prep work to add CourseAuthoringPage component.

Decided the course-detail sub-directory didn’t make much sense, given component structure, and moved it up to src.

These functions will be used in a CourseAuthoringPage component to load course detail data and display the Header and Footer in one common place, wrapping all the existing course authoring pages (proctoring and pages & resources)

It will also replace LmsApiService.js

* Minor style refactorings.

(This commit had originally made some changes to how courseId was passed in to these two components, but I decided to back it out… but the style stuff is worth adding as a fixed nit.)

* Refactor course detail loading and top-level course authoring components

This commit does a few things:

- Factors course detail data loading out of the Header.
- Loads that data in CourseAuthoringPage instead, adding it to redux and then passing it to the Header from there.
- Deletes LmsApiService, which is no longer used.
- Changes the route paths to be more canonical and entity-oriented, i.e., the first part of the route is the course, followed by the specific page about that course to load, rather than the other way around.  This more naturally allows us to use react-router to extract the common course detail loading code that only depends on the courseId.

* Refactoring routes code a bit to pass courseId into components

Didn’t like how CourseAuthoringPage, LegacyProctoringRoute, and CourseAuthoringRoutes all reached into the parent route to find the courseId, so passed it in instead.

* Updating README with more detail on routes in the MFE.
This commit is contained in:
David Joy
2021-01-07 13:16:35 -05:00
committed by GitHub
parent eaefefda26
commit 9c63ab8044
23 changed files with 634 additions and 272 deletions

View File

@@ -1,15 +1,17 @@
|Build Status| |Codecov| |license|
frontend-app-course-authoring
=================================
=============================
Please tag `@edx/teaching-and-learning <https://github.com/orgs/edx/teams/teaching-and-learning>`_ on any PRs or issues. Thanks.
**Prerequisite**
Prerequisite
------------
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.ecommerce`` that should give you everything you need as a companion to this frontend.
`Devstack <https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/installation/index.html>`_. If you start Devstack with ``make dev.up.studio`` that should give you everything you need as a companion to this frontend.
**Installation and Startup**
Installation and Startup
------------------------
1. Clone the repo:
@@ -25,23 +27,16 @@ Please tag `@edx/teaching-and-learning <https://github.com/orgs/edx/teams/teachi
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
Project Structure
-----------------
If your devstack includes the default Demo course, you can visit the following URLs to see content:
The source for this project is organized into nested submodules according to the ADR `Feature-based Application Organization <https://github.com/edx/frontend-app-course-authoring/blob/master/docs/decisions/0002-feature-based-application-organization.rst>`_.
- `Proctored Exam Settings <http://localhost:2001/course-v1:edX+DemoX+Demo_Course/proctored-exam-settings>`_
- `Pages and Resources <http://localhost:2001/course-v1:edX+DemoX+Demo_Course/pages>`_ (work in progress)
Build Process Notes
-------------------
**Production Build**
Production Build
----------------
The production build is created with ``npm run build``.
Internationalization
--------------------
Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-platform/module-Internationalization.html>`_ for documentation on internationalization. The documentation explains how to use it, and the `How To <https://github.com/edx/frontend-i18n/blob/master/docs/how_tos/i18n.rst>`_ has more detail.
.. |Build Status| image:: https://api.travis-ci.com/edx/frontend-app-course-authoring.svg?branch=master
:target: https://travis-ci.com/edx/frontend-app-course-authoring
.. |Codecov| image:: https://codecov.io/gh/edx/frontend-app-course-authoring/branch/master/graph/badge.svg

315
package-lock.json generated
View File

@@ -1398,40 +1398,6 @@
}
}
},
"@edx/frontend-component-header": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-2.0.5.tgz",
"integrity": "sha512-eiIT2RXgPt3BoMwtc/2MdMencWfuRcgye5QyjFhp40a2krQOi98aUM5C3d72Zphvzl5yekxP2s2QYidIg5BssA==",
"requires": {
"babel-polyfill": "6.26.0",
"react-responsive": "8.0.3",
"react-transition-group": "4.3.0"
},
"dependencies": {
"react-responsive": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.0.3.tgz",
"integrity": "sha512-F9VXyLao7O8XHXbLjQbIr4+mC6Zr0RDTwNjd7ixTmYEAyKyNanBkLkFchNaMZgszoSK6PgSs/3m/QDWw33/gpg==",
"requires": {
"hyphenate-style-name": "^1.0.0",
"matchmediaquery": "^0.3.0",
"prop-types": "^15.6.1",
"shallow-equal": "^1.1.0"
}
},
"react-transition-group": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.3.0.tgz",
"integrity": "sha512-1qRV1ZuVSdxPlPf4O8t7inxUGpdyO5zG9IoNfJxSO0ImU2A1YWkEQvFPuIPZmMLkg5hYs7vv5mMOyfgSkvAwvw==",
"requires": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
}
}
}
},
"@edx/frontend-platform": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-1.8.0.tgz",
@@ -1513,9 +1479,9 @@
}
},
"@fortawesome/react-fontawesome": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.13.tgz",
"integrity": "sha512-/HrLnIft5Ks2511Pz6TxHBIctC9QalVscAC64sufQ4sJH/sXaQlG3uR9LCu6VpEwkBemgcBLrz/QPNP/ddbjDg==",
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.14.tgz",
"integrity": "sha512-4wqNb0gRLVaBm/h+lGe8UfPPivcbuJ6ecI4hIgW0LjI7kzpYB9FkN0L9apbVzg+lsBdcTf0AlBtODjcSX5mmKA==",
"requires": {
"prop-types": "^15.7.2"
}
@@ -2435,9 +2401,27 @@
"dev": true
},
"@popperjs/core": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.5.4.tgz",
"integrity": "sha512-ZpKr+WTb8zsajqgDkvCEWgp6d5eJT6Q63Ng2neTbzBO76Lbe91vX/iVIW9dikq+Fs3yEo+ls4cxeXABD2LtcbQ=="
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.6.0.tgz",
"integrity": "sha512-cPqjjzuFWNK3BSKLm0abspP0sp/IGOli4p5I5fKFAzdS8fvjdOwDCfZqAaIiXd9lPkOWi3SUUfZof3hEb7J/uw=="
},
"@reduxjs/toolkit": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.5.0.tgz",
"integrity": "sha512-E/FUraRx+8guw9Hlg/Ja8jI/hwCrmIKed8Annt9YsZw3BQp+F24t5I5b2OWR6pkEHY4hn1BgP08FrTZFRKsdaQ==",
"requires": {
"immer": "^8.0.0",
"redux": "^4.0.0",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0"
},
"dependencies": {
"immer": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-8.0.0.tgz",
"integrity": "sha512-jm87NNBAIG4fHwouilCHIecFXp5rMGkiFrAuhVO685UnMAlOneEAnOyzPt8OnP47TC11q/E7vpzZe0WvwepFTg=="
}
}
},
"@restart/context": {
"version": "2.1.4",
@@ -3952,10 +3936,25 @@
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz",
"integrity": "sha1-N5k3q8Z9eJWXCtxiHyhM2WbPIVM=",
"dev": true,
"requires": {
"babel-runtime": "^6.26.0",
"core-js": "^2.5.0",
"regenerator-runtime": "^0.10.5"
},
"dependencies": {
"core-js": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
"dev": true
},
"regenerator-runtime": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz",
"integrity": "sha1-M2w+/BIgrc7dosn6tntaeVWjNlg=",
"dev": true
}
}
},
"babel-preset-current-node-syntax": {
@@ -3991,15 +3990,23 @@
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
"integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
"dev": true,
"requires": {
"core-js": "^2.4.0",
"regenerator-runtime": "^0.11.0"
},
"dependencies": {
"core-js": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
"dev": true
},
"regenerator-runtime": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
"dev": true
}
}
},
@@ -4891,6 +4898,15 @@
}
}
},
"call-bind": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.0.tgz",
"integrity": "sha512-AEXsYIyyDY3MCzbwdhzG3Jx1R0J2wetQyUynn6dYHAO+bg8l1k7jwZtRv4ryryFs7EP+NDlikJlVe59jr0cM2w==",
"requires": {
"function-bind": "^1.1.1",
"get-intrinsic": "^1.0.0"
}
},
"call-me-maybe": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz",
@@ -5786,9 +5802,9 @@
}
},
"core-js": {
"version": "2.6.11",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz",
"integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg=="
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.8.1.tgz",
"integrity": "sha512-9Id2xHY1W7m8hCl8NkhQn5CufmF/WuR30BTRewvCXc1aZd3kMECwNZ69ndLbekKfakw9Rf2Xyc+QR6E7Gg+obg=="
},
"core-js-compat": {
"version": "3.6.5",
@@ -6796,24 +6812,39 @@
}
},
"domutils": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.4.2.tgz",
"integrity": "sha512-NKbgaM8ZJOecTZsIzW5gSuplsX2IWW2mIK7xVr8hTQF2v1CJWTmLZ1HOCh5sH+IzVPAGE5IucooOkvwBRAdowA==",
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.4.4.tgz",
"integrity": "sha512-jBC0vOsECI4OMdD0GC9mGn7NXPLb+Qt6KW1YDQzeQYRUFKmNG8lh7mO5HiELfr+lLQE7loDVI4QcAxV80HS+RA==",
"requires": {
"dom-serializer": "^1.0.1",
"domelementtype": "^2.0.1",
"domhandler": "^3.3.0"
"domhandler": "^4.0.0"
},
"dependencies": {
"dom-serializer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.1.0.tgz",
"integrity": "sha512-ox7bvGXt2n+uLWtCRLybYx60IrOlWL/aCebWJk1T0d4m3y2tzf4U3ij9wBMUb6YJZpz06HCCYuyCDveE2xXmzQ==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.2.0.tgz",
"integrity": "sha512-n6kZFH/KlCrqs/1GHMOd5i2fd/beQHuehKdWvNNffbGHTr/almdhuVvTVFb3V7fglz+nC50fFusu3lY33h12pA==",
"requires": {
"domelementtype": "^2.0.1",
"domhandler": "^3.0.0",
"domhandler": "^4.0.0",
"entities": "^2.0.0"
}
},
"domhandler": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.0.0.tgz",
"integrity": "sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA==",
"requires": {
"domelementtype": "^2.1.0"
},
"dependencies": {
"domelementtype": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.1.0.tgz",
"integrity": "sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w=="
}
}
}
}
},
@@ -8518,13 +8549,59 @@
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"function.prototype.name": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.2.tgz",
"integrity": "sha512-C8A+LlHBJjB2AdcRPorc5JvJ5VUoWlXdEHLOJdCI7kjHEtGTpHQUiqMvCIKUwIsGwZX2jZJy761AXsn356bJQg==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.3.tgz",
"integrity": "sha512-H51qkbNSp8mtkJt+nyW1gyStBiKZxfRqySNUR99ylq6BPXHKI4SEvIlTKp4odLfjRKJV04DFWMU3G/YRlQOsag==",
"requires": {
"call-bind": "^1.0.0",
"define-properties": "^1.1.3",
"es-abstract": "^1.17.0-next.1",
"functions-have-names": "^1.2.0"
"es-abstract": "^1.18.0-next.1",
"functions-have-names": "^1.2.1"
},
"dependencies": {
"es-abstract": {
"version": "1.18.0-next.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz",
"integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==",
"requires": {
"es-to-primitive": "^1.2.1",
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.1",
"is-callable": "^1.2.2",
"is-negative-zero": "^2.0.0",
"is-regex": "^1.1.1",
"object-inspect": "^1.8.0",
"object-keys": "^1.1.1",
"object.assign": "^4.1.1",
"string.prototype.trimend": "^1.0.1",
"string.prototype.trimstart": "^1.0.1"
}
},
"is-callable": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz",
"integrity": "sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA=="
},
"is-regex": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz",
"integrity": "sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==",
"requires": {
"has-symbols": "^1.0.1"
}
},
"object.assign": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz",
"integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==",
"requires": {
"call-bind": "^1.0.0",
"define-properties": "^1.1.3",
"has-symbols": "^1.0.1",
"object-keys": "^1.1.1"
}
}
}
},
"functional-red-black-tree": {
@@ -8534,9 +8611,9 @@
"dev": true
},
"functions-have-names": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.1.tgz",
"integrity": "sha512-j48B/ZI7VKs3sgeI2cZp7WXWmZXu7Iq5pl5/vptV5N2mq+DGFuS/ulaDjtaoLpYzuD6u8UgrUKHfgo7fDTSiBA=="
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.2.tgz",
"integrity": "sha512-bLgc3asbWdwPbx2mNk2S49kmJCuQeu0nfmaOgbs8WIyzzkw3r4htszdIi9Q9EMezDPTYuJx2wvjZ/EwgAthpnA=="
},
"gauge": {
"version": "2.7.4",
@@ -8612,6 +8689,16 @@
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true
},
"get-intrinsic": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.0.2.tgz",
"integrity": "sha512-aeX0vrFm21ILl3+JpFFRNe9aUvp6VFZb2/CTbgLb8j75kOhvoNYjt9d8KA/tJG4gSo8nzEDedRl0h7vDmBYRVg==",
"requires": {
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.1"
}
},
"get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@@ -8831,11 +8918,6 @@
"integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=",
"dev": true
},
"gud": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz",
"integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw=="
},
"gzip-size": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-5.1.1.tgz",
@@ -10283,6 +10365,11 @@
"integrity": "sha1-q5124dtM7VHjXeDHLr7PCfc0zeg=",
"dev": true
},
"is-negative-zero": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz",
"integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w=="
},
"is-number": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
@@ -13497,9 +13584,9 @@
"integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ=="
},
"lodash-es": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.15.tgz",
"integrity": "sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ=="
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.20.tgz",
"integrity": "sha512-JD1COMZsq8maT6mnuz1UMV0jvYD0E0aUsSOdrr1/nAG3dhqQXwRRgeW0cSqH1U43INKcqxaiVIQNOUDld7gRDA=="
},
"lodash.camelcase": {
"version": "4.3.0",
@@ -14025,13 +14112,22 @@
"dev": true
},
"mini-create-react-context": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.3.2.tgz",
"integrity": "sha512-2v+OeetEyliMt5VHMXsBhABoJ0/M4RCe7fatd/fBy6SMiKazUSEt3gxxypfnk2SHMkdBYvorHRoQxuGoiwbzAw==",
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.3.3.tgz",
"integrity": "sha512-TtF6hZE59SGmS4U8529qB+jJFeW6asTLDIpPgvPLSCsooAwJS7QprHIFTqv9/Qh3NdLwQxFYgiHX5lqb6jqzPA==",
"requires": {
"@babel/runtime": "^7.4.0",
"gud": "^1.0.0",
"tiny-warning": "^1.0.2"
"@babel/runtime": "^7.12.1",
"tiny-warning": "^1.0.3"
},
"dependencies": {
"@babel/runtime": {
"version": "7.12.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz",
"integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
}
}
},
"mini-css-extract-plugin": {
@@ -16481,9 +16577,9 @@
}
},
"react": {
"version": "16.12.0",
"resolved": "https://registry.npmjs.org/react/-/react-16.12.0.tgz",
"integrity": "sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA==",
"version": "16.14.0",
"resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz",
"integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
@@ -16516,9 +16612,9 @@
}
},
"react-clientside-effect": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.2.tgz",
"integrity": "sha512-nRmoyxeok5PBO6ytPvSjKp9xwXg9xagoTK1mMjwnQxqM9Hd7MNPl+LS1bOSOe+CV2+4fnEquc7H/S8QD3q697A==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.3.tgz",
"integrity": "sha512-96HOmjJjjemxZD4qMdaMWFl3d/3Dqm/MAXnThoP8+jQihevYs8VzooqYWlVEPmkp9tVIa06i67R7FF1qsuzUwQ==",
"requires": {
"@babel/runtime": "^7.0.0"
}
@@ -16800,14 +16896,14 @@
}
},
"react-dom": {
"version": "16.12.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.12.0.tgz",
"integrity": "sha512-LMxFfAGrcS3kETtQaCkTKjMiifahaMySFDn71fZUNpPHZQEzmk/GiAeIT8JSOrHB23fnuCOMruL2a8NYlw+8Gw==",
"version": "16.14.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz",
"integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"scheduler": "^0.18.0"
"scheduler": "^0.19.1"
}
},
"react-error-overlay": {
@@ -16830,9 +16926,9 @@
}
},
"react-focus-on": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/react-focus-on/-/react-focus-on-3.5.0.tgz",
"integrity": "sha512-RqGAHOxhRAaMSVHIN5IpY7YL6AJkD/DMa/+iPDV7aB6XWRQfg3v2q35egIZgMWP2xhXaRVai3B80dpVWyj4Rcw==",
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/react-focus-on/-/react-focus-on-3.5.1.tgz",
"integrity": "sha512-6iE56nYNwVU6Pke362TjqRLz/G7DBGnEugkxhPAhpXEZW5og3vhc9qDPlyiHgxoiY9kYTWjdAEFz4nJgSluANg==",
"requires": {
"aria-hidden": "^1.1.1",
"react-focus-lock": "^2.3.1",
@@ -16900,11 +16996,6 @@
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"regenerator-runtime": {
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
}
}
},
@@ -16927,9 +17018,9 @@
}
},
"react-remove-scroll": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.4.0.tgz",
"integrity": "sha512-BZIO3GaEs0Or1OhA5C//n1ibUP1HdjJmqUVUsOCMxwoIpaCocbB9TFKwHOkBa/nyYy3slirqXeiPYGwdSDiseA==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.4.1.tgz",
"integrity": "sha512-K7XZySEzOHMTq7dDwcHsZA6Y7/1uX5RsWhRXVYv8rdh+y9Qz2nMwl9RX/Mwnj/j7JstCGmxyfyC0zbVGXYh3mA==",
"requires": {
"react-remove-scroll-bar": "^2.1.0",
"react-style-singleton": "^2.1.0",
@@ -16939,9 +17030,9 @@
}
},
"react-remove-scroll-bar": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.1.0.tgz",
"integrity": "sha512-5X5Y5YIPjIPrAoMJxf6Pfa7RLNGCgwZ95TdnVPgPuMftRfO8DaC7F4KP1b5eiO8hHbe7u+wZNDbYN5WUTpv7+g==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.1.1.tgz",
"integrity": "sha512-IZbfQPSozIr8ylHE9MFcQeb2TTzj4abfE7OBXjmtUeXQ5h6ColGKDNo5h7OmzrJRilAx3YIKBf3jb0yrb31BJQ==",
"requires": {
"react-style-singleton": "^2.1.0",
"tslib": "^1.0.0"
@@ -16990,9 +17081,9 @@
}
},
"react-style-singleton": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.1.0.tgz",
"integrity": "sha512-DH4ED+YABC1dhvSDYGGreAHmfuTXj6+ezT3CmHoqIEfxNgEYfIMoOtmbRp42JsUst3IPqBTDL+8r4TF7EWhIHw==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.1.1.tgz",
"integrity": "sha512-jNRp07Jza6CBqdRKNgGhT3u9umWvils1xsuMOjZlghBDH2MU0PL2WZor4PGYjXpnRCa9DQSlHMs/xnABWOwYbA==",
"requires": {
"get-nonce": "^1.0.0",
"invariant": "^2.2.4",
@@ -17197,6 +17288,11 @@
"symbol-observable": "^1.2.0"
}
},
"redux-thunk": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz",
"integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw=="
},
"reflect.ownkeys": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz",
@@ -17218,9 +17314,9 @@
}
},
"regenerator-runtime": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz",
"integrity": "sha1-M2w+/BIgrc7dosn6tntaeVWjNlg="
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
},
"regenerator-transform": {
"version": "0.14.5",
@@ -17533,6 +17629,11 @@
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
"dev": true
},
"reselect": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz",
"integrity": "sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA=="
},
"resolve": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",
@@ -18218,9 +18319,9 @@
}
},
"scheduler": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.18.0.tgz",
"integrity": "sha512-agTSHR1Nbfi6ulI0kYNK0203joW2Y5W4po4l+v03tOoiJKpTBbxpNhWDvqc/4IcOw+KLmSiQLTasZ4cab2/UWQ==",
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz",
"integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"

View File

@@ -36,7 +36,6 @@
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@^1.1.0",
"@edx/frontend-component-footer": "10.0.11",
"@edx/frontend-component-header": "2.0.5",
"@edx/frontend-platform": "1.8.0",
"@edx/paragon": "12.2.0",
"@fortawesome/fontawesome-svg-core": "1.2.28",
@@ -44,19 +43,21 @@
"@fortawesome/free-regular-svg-icons": "5.11.2",
"@fortawesome/free-solid-svg-icons": "5.11.2",
"@fortawesome/react-fontawesome": "0.1.9",
"babel-polyfill": "6.26.0",
"@reduxjs/toolkit": "^1.5.0",
"classnames": "^2.2.6",
"core-js": "^3.8.1",
"email-validator": "^2.0.4",
"moment": "^2.27.0",
"prop-types": "15.7.2",
"react": "16.12.0",
"react-dom": "16.12.0",
"react-redux": "7.1.3",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"react-redux": "^7.1.3",
"react-responsive": "^8.1.0",
"react-router": "5.1.2",
"react-router-dom": "5.1.2",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"react-transition-group": "^4.4.1",
"redux": "4.0.5"
"redux": "4.0.5",
"regenerator-runtime": "^0.13.7"
},
"devDependencies": {
"@edx/frontend-build": "^3.0.0",

View File

@@ -0,0 +1,44 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import Footer from '@edx/frontend-component-footer';
import { useDispatch } from 'react-redux';
import Header from './studio-header/Header';
import { fetchCourseDetail } from './data/thunks';
import { useModel } from './generic/model-store';
export default function CourseAuthoringPage({ courseId, children }) {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchCourseDetail(courseId));
}, [courseId]);
const courseDetail = useModel('courseDetails', courseId);
const courseNumber = courseDetail ? courseDetail.number : null;
const courseOrg = courseDetail ? courseDetail.org : null;
const courseTitle = courseDetail ? courseDetail.name : courseId;
return (
<>
<Header
courseNumber={courseNumber}
courseOrg={courseOrg}
courseTitle={courseTitle}
courseId={courseId}
/>
{children}
<Footer />
</>
);
}
CourseAuthoringPage.propTypes = {
children: PropTypes.node,
courseId: PropTypes.string.isRequired,
};
CourseAuthoringPage.defaultProps = {
children: null,
};

View File

@@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Switch, useRouteMatch } from 'react-router';
import { PageRoute } from '@edx/frontend-platform/react';
import CourseAuthoringPage from './CourseAuthoringPage';
import { CoursePageResources } from './course-page-resources';
import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
*
* /course/:courseId
*
* Meaning that their absolute paths look like:
*
* /course/:courseId/course-pages
* /course/:courseId/proctored-exam-settings
*
* This component and CourseAuthoringPage should maybe be combined once we no longer need to have
* CourseAuthoringPage split out for use in LegacyProctoringRoute. Once that route is removed, we
* can move the Header/Footer rendering to this component and likely pull the course detail loading
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
*/
export default function CourseAuthoringRoutes({ courseId }) {
const { path } = useRouteMatch();
return (
<CourseAuthoringPage courseId={courseId}>
<Switch>
<PageRoute path={`${path}/pages`}>
<CoursePageResources courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/proctored-exam-settings`}>
<ProctoredExamSettings courseId={courseId} />
</PageRoute>
</Switch>
</CourseAuthoringPage>
);
}
CourseAuthoringRoutes.propTypes = {
courseId: PropTypes.string.isRequired,
};

View File

@@ -1,7 +1,8 @@
import PropTypes from 'prop-types';
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import { Button } from '@edx/paragon';
import CoursePageConfigCard from './course-page/CoursePageConfigCard';
import messages from './messages';
@@ -64,7 +65,7 @@ const coursePages = [
},
];
function CoursePageResources({ intl, courseId }) {
function CoursePageResources({ courseId, intl }) {
const { config } = useContext(AppContext);
const lmsCourseURL = `${config.LMS_BASE_URL}/courses/${courseId}`;
return (
@@ -99,9 +100,9 @@ function CoursePageResources({ intl, courseId }) {
{intl.formatMessage(messages['resources.custom.description'])}
</div>
<div className="col-2 text-right">
<a className="btn btn-outline-info" href="/#" role="button">
<Button variant="outline-primary">
{intl.formatMessage(messages['resources.newPage.button'])}
</a>
</Button>
</div>
</div>
</div>
@@ -111,8 +112,8 @@ function CoursePageResources({ intl, courseId }) {
}
CoursePageResources.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(CoursePageResources);

View File

@@ -1,11 +1,12 @@
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCog } from '@fortawesome/free-solid-svg-icons';
import { Button } from '@edx/paragon';
import classNames from 'classnames';
import messages from '../messages';
const CoursePageShape = PropTypes.shape({
@@ -45,7 +46,7 @@ function CoursePageConfigCard({ intl, coursePage }) {
{coursePage.showEnable && !coursePage.isEnabled && (
<div className="d-flex justify-content-center">
<Button className="btn btn-outline-primary">
<Button variant="outline-primary">
{intl.formatMessage(messages['enable.button'])}
</Button>
</div>

17
src/data/api.js Normal file
View File

@@ -0,0 +1,17 @@
/* eslint-disable import/prefer-default-export */
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
function normalizeCourseDetail(data) {
return {
id: data.course_id,
...camelCaseObject(data),
};
}
export async function getCourseDetail(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/courses/v1/courses/${courseId}`);
return normalizeCourseDetail(data);
}

View File

@@ -1,22 +0,0 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig, ensureConfig } from '@edx/frontend-platform';
ensureConfig([
'LMS_BASE_URL',
], 'LMS API service');
const lmsBaseUrl = getConfig().LMS_BASE_URL;
class LmsApiService {
static getCourseDetailsUrl(courseID) {
return `${lmsBaseUrl}/api/courses/v1/courses/${courseID}`;
}
static getCourseDetailsData(courseID) {
const apiClient = getAuthenticatedHttpClient();
const url = LmsApiService.getCourseDetailsUrl(courseID);
return apiClient.get(url);
}
}
export default LmsApiService;

28
src/data/slice.js Normal file
View File

@@ -0,0 +1,28 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const LOADING = 'LOADING';
export const LOADED = 'LOADED';
export const FAILED = 'FAILED';
const slice = createSlice({
name: 'courseDetail',
initialState: {
courseId: null,
status: null,
},
reducers: {
updateStatus: (state, { payload }) => {
state.courseId = payload.courseId;
state.status = payload.status;
},
},
});
export const {
updateStatus,
} = slice.actions;
export const {
reducer,
} = slice;

24
src/data/thunks.js Normal file
View File

@@ -0,0 +1,24 @@
import { addModel } from '../generic/model-store';
import { getCourseDetail } from './api';
import {
updateStatus,
LOADING,
LOADED,
FAILED,
} from './slice';
/* eslint-disable import/prefer-default-export */
export function fetchCourseDetail(courseId) {
return async (dispatch) => {
dispatch(updateStatus({ courseId, status: LOADING }));
try {
const courseDetail = await getCourseDetail(courseId);
dispatch(updateStatus({ courseId, status: LOADED }));
dispatch(addModel({ modelType: 'courseDetails', model: courseDetail }));
} catch (error) {
dispatch(updateStatus({ courseId, status: FAILED }));
}
};
}

View File

@@ -0,0 +1,17 @@
import { useSelector, shallowEqual } from 'react-redux';
export function useModel(type, id) {
return useSelector(
state => (state.models[type] !== undefined ? state.models[type][id] : undefined),
shallowEqual,
);
}
export function useModels(type, ids) {
return useSelector(
state => ids.map(
id => (state.models[type] !== undefined ? state.models[type][id] : undefined),
),
shallowEqual,
);
}

View File

@@ -0,0 +1,16 @@
export {
reducer,
addModel,
addModels,
addModelsMap,
updateModel,
updateModels,
updateModelsMap,
removeModel,
removeModels,
} from './slice';
export {
useModel,
useModels,
} from './hooks';

View File

@@ -0,0 +1,77 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
function add(state, modelType, model) {
const { id } = model;
if (state[modelType] === undefined) {
state[modelType] = {};
}
state[modelType][id] = model;
}
function update(state, modelType, model) {
if (state[modelType] === undefined) {
state[modelType] = {};
}
state[modelType][model.id] = { ...state[modelType][model.id], ...model };
}
function remove(state, modelType, id) {
if (state[modelType] === undefined) {
state[modelType] = {};
}
delete state[modelType][id];
}
const slice = createSlice({
name: 'models',
initialState: {},
reducers: {
addModel: (state, { payload }) => {
const { modelType, model } = payload;
add(state, modelType, model);
},
addModels: (state, { payload }) => {
const { modelType, models } = payload;
models.forEach(model => add(state, modelType, model));
},
addModelsMap: (state, { payload }) => {
const { modelType, modelsMap } = payload;
Object.values(modelsMap).forEach(model => add(state, modelType, model));
},
updateModel: (state, { payload }) => {
const { modelType, model } = payload;
update(state, modelType, model);
},
updateModels: (state, { payload }) => {
const { modelType, models } = payload;
models.forEach(model => update(state, modelType, model));
},
updateModelsMap: (state, { payload }) => {
const { modelType, modelsMap } = payload;
Object.values(modelsMap).forEach(model => update(state, modelType, model));
},
removeModel: (state, { payload }) => {
const { modelType, id } = payload;
remove(state, modelType, id);
},
removeModels: (state, { payload }) => {
const { modelType, ids } = payload;
ids.forEach(id => remove(state, modelType, id));
},
},
});
export const {
addModel,
addModels,
addModelsMap,
updateModel,
updateModels,
updateModelsMap,
removeModel,
removeModels,
} = slice.actions;
export const { reducer } = slice;

View File

@@ -1,4 +1,5 @@
import 'babel-polyfill';
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import {
APP_INIT_ERROR, APP_READY, subscribe, initialize,
@@ -8,49 +9,41 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { Route, Switch } from 'react-router-dom';
import { messages as headerMessages } from '@edx/frontend-component-header';
import Footer, { messages as footerMessages } from '@edx/frontend-component-footer';
import { messages as footerMessages } from '@edx/frontend-component-footer';
import appMessages from './i18n';
import { CoursePageResources } from './course-page-resources';
import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings';
import StudioHeader from './studio-header/Header';
import initializeStore from './store';
import './index.scss';
import './assets/favicon.ico';
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
import LegacyProctoringRoute from './proctored-exam-settings/LegacyProctoringRoute';
subscribe(APP_READY, () => {
ReactDOM.render(
<AppProvider>
<AppProvider store={initializeStore()}>
<Switch>
<Route
path="/proctored-exam-settings/:course_id"
exact
path="/proctored-exam-settings/:courseId"
render={({ match }) => {
const courseId = decodeURIComponent(match.params.course_id);
const { params: { courseId } } = match;
/* See component for details on what this is */
return (
<>
<StudioHeader courseId={courseId} />
<ProctoredExamSettings courseId={courseId} />
</>
<LegacyProctoringRoute courseId={courseId} />
);
}}
/>
<Route
path="/course-pages/:course_id"
path="/course/:courseId"
render={({ match }) => {
const courseId = decodeURIComponent(match.params.course_id);
const { params: { courseId } } = match;
return (
<>
<StudioHeader courseId={courseId} />
<CoursePageResources courseId={courseId} />
<Footer />
</>
<CourseAuthoringRoutes courseId={courseId} />
);
}}
/>
</Switch>
<Footer />
</AppProvider>,
document.getElementById('root'),
);
@@ -63,7 +56,6 @@ subscribe(APP_INIT_ERROR, (error) => {
initialize({
messages: [
appMessages,
headerMessages,
footerMessages,
],
requireAuthenticatedUser: true,

View File

@@ -5,7 +5,6 @@
@import './course-page-resources/index.scss';
@import "~@edx/frontend-component-header/dist/index";
@import "~@edx/frontend-component-footer/dist/footer";
@import "proctored-exam-settings/proctoredExamSettings.scss";

View File

@@ -0,0 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import CourseAuthoringPage from '../CourseAuthoringPage';
import ProctoredExamSettings from './ProctoredExamSettings';
/**
* LEGACY proctored exam settings route! This is an EXPAND and CONTRACT refactoring - this
* legacy route exists because we're in an EXPANDED state. Delete this route when backends
* no longer send users here.
*
* The refactoring is intended to organize our routes by the primary entity first (a course),
* and the sub-entity second (proctoring settings). In CourseAuthoringContainer below, you'll see
* that we flip the routes so that the pages follow the courseId, since they're conceptually
* children of it.
*/
export default function LegacyProctoringRoute({ courseId }) {
return (
<CourseAuthoringPage courseId={courseId}>
<ProctoredExamSettings courseId={courseId} />
</CourseAuthoringPage>
);
}
LegacyProctoringRoute.propTypes = {
courseId: PropTypes.string.isRequired,
};

View File

@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import EmailValidator from 'email-validator';
import moment from 'moment';
import {
@@ -15,7 +15,7 @@ import {
import messages from './ProctoredExamSettings.messages';
import StudioApiService from '../data/services/StudioApiService';
function ExamSettings(props) {
function ProctoredExamSettings({ courseId, intl }) {
const [loading, setLoading] = useState(true);
const [loaded, setLoaded] = useState(false);
const [loadingConnectionError, setLoadingConnectionError] = useState(false);
@@ -95,7 +95,7 @@ function ExamSettings(props) {
}
setSubmissionInProgress(true);
StudioApiService.saveProctoredExamSettingsData(props.courseId, dataToPostBack).then(() => {
StudioApiService.saveProctoredExamSettingsData(courseId, dataToPostBack).then(() => {
setSaveSuccess(true);
setSaveError(false);
setSubmissionInProgress(false);
@@ -110,7 +110,7 @@ function ExamSettings(props) {
event.preventDefault();
if (proctoringProvider === 'proctortrack' && !EmailValidator.validate(proctortrackEscalationEmail)) {
if (proctortrackEscalationEmail === '') {
const errorMessage = props.intl.formatMessage(messages['authoring.examsettings.escalationemail.error.blank']);
const errorMessage = intl.formatMessage(messages['authoring.examsettings.escalationemail.error.blank']);
setFormStatus({
isValid: false,
@@ -122,7 +122,7 @@ function ExamSettings(props) {
},
});
} else {
const errorMessage = props.intl.formatMessage(messages['authoring.examsettings.escalationemail.error.invalid']);
const errorMessage = intl.formatMessage(messages['authoring.examsettings.escalationemail.error.invalid']);
setFormStatus({
isValid: false,
@@ -181,7 +181,7 @@ function ExamSettings(props) {
return (
<>
<div>{props.intl.formatMessage(messages[messageId], { numOfErrors })}</div>
<div>{intl.formatMessage(messages[messageId], { numOfErrors })}</div>
<ul>
{errors}
</ul>
@@ -210,7 +210,7 @@ function ExamSettings(props) {
<Form.Check
type="checkbox"
id="enableProctoredExams"
label={props.intl.formatMessage(messages['authoring.examsettings.enableproctoredexams.label'])}
label={intl.formatMessage(messages['authoring.examsettings.enableproctoredexams.label'])}
aria-describedby="enableProctoredExamsHelpText"
onChange={onEnableProctoredExamsChange}
checked={enableProctoredExams}
@@ -240,7 +240,7 @@ function ExamSettings(props) {
type="radio"
id="allowOptingOutYes"
name="allowOptingOut"
label={props.intl.formatMessage(messages['authoring.examsettings.allowoptout.yes'])}
label={intl.formatMessage(messages['authoring.examsettings.allowoptout.yes'])}
inline
checked={allowOptingOut}
onChange={() => onAllowOptingOutChange(true)}
@@ -250,7 +250,7 @@ function ExamSettings(props) {
type="radio"
id="allowOptingOutNo"
name="allowOptingOut"
label={props.intl.formatMessage(messages['authoring.examsettings.allowoptout.no'])}
label={intl.formatMessage(messages['authoring.examsettings.allowoptout.no'])}
inline
checked={!allowOptingOut}
onChange={() => onAllowOptingOutChange(false)}
@@ -260,7 +260,7 @@ function ExamSettings(props) {
<FormattedMessage
id="authoring.examsettings.allowoptout.help"
defaultMessage={`
If this value is "Yes", learners can choose to take proctored exams without proctoring.
If this value is "Yes", learners can choose to take proctored exams without proctoring.
If this value is "No", all learners must take the exam with proctoring.
`}
description="Help text for proctored exam opt out radio selection"
@@ -289,7 +289,7 @@ function ExamSettings(props) {
{getProctoringProviderOptions(availableProctoringProviders)}
</Form.Control>
<Form.Text id="proctoringProviderHelpText">
{cannotEditProctoringProvider() ? props.intl.formatMessage(messages['authoring.examsettings.provider.help.aftercoursestart']) : props.intl.formatMessage(messages['authoring.examsettings.provider.help'])}
{cannotEditProctoringProvider() ? intl.formatMessage(messages['authoring.examsettings.provider.help.aftercoursestart']) : intl.formatMessage(messages['authoring.examsettings.provider.help'])}
</Form.Text>
</Form.Group>
)}
@@ -318,7 +318,7 @@ function ExamSettings(props) {
<FormattedMessage
id="authoring.examsettings.escalationemail.help"
defaultMessage={`
Required if "proctortrack" is selected as your proctoring provider. Enter an email address to be
Required if "proctortrack" is selected as your proctoring provider. Enter an email address to be
contacted by the support team whenever there are escalations (e.g. appeals, delayed reviews, etc.).
`}
description="Help text explaining escalation email field."
@@ -340,7 +340,7 @@ function ExamSettings(props) {
<Form.Check
type="radio"
id="createZendeskTicketsYes"
label={props.intl.formatMessage(messages['authoring.examsettings.createzendesk.yes'])}
label={intl.formatMessage(messages['authoring.examsettings.createzendesk.yes'])}
inline
name="createZendeskTickets"
checked={createZendeskTickets}
@@ -350,7 +350,7 @@ function ExamSettings(props) {
<Form.Check
type="radio"
id="createZendeskTicketsNo"
label={props.intl.formatMessage(messages['authoring.examsettings.createzendesk.no'])}
label={intl.formatMessage(messages['authoring.examsettings.createzendesk.no'])}
inline
name="createZendeskTickets"
checked={!createZendeskTickets}
@@ -413,10 +413,10 @@ function ExamSettings(props) {
<FormattedMessage
id="authoring.examsettings.alert.error.connection"
defaultMessage={`
We encountered a technical error when loading this page. This might be a temporary issue,
We encountered a technical error when loading this page. This might be a temporary issue,
so please try again in a few minutes. If the problem persists, please go to {support_link} for help.
`}
values={{ support_link: <Alert.Link href="https://support.edx.org/hc/en-us">{props.intl.formatMessage(messages['authoring.examsettings.support.text'])}</Alert.Link> }}
values={{ support_link: <Alert.Link href="https://support.edx.org/hc/en-us">{intl.formatMessage(messages['authoring.examsettings.support.text'])}</Alert.Link> }}
description=""
/>
</Alert>
@@ -438,7 +438,7 @@ function ExamSettings(props) {
}
function renderSaveSuccess() {
const studioCourseRunURL = StudioApiService.getStudioCourseRunUrl(props.courseId);
const studioCourseRunURL = StudioApiService.getStudioCourseRunUrl(courseId);
return (
<Alert
variant="success"
@@ -478,7 +478,7 @@ function ExamSettings(props) {
If the problem persists,
please go to {support_link} for help.
`}
values={{ support_link: <Alert.Link href="https://support.edx.org/hc/en-us">{props.intl.formatMessage(messages['authoring.examsettings.support.text'])}</Alert.Link> }}
values={{ support_link: <Alert.Link href="https://support.edx.org/hc/en-us">{intl.formatMessage(messages['authoring.examsettings.support.text'])}</Alert.Link> }}
/>
</Alert>
);
@@ -486,7 +486,7 @@ function ExamSettings(props) {
useEffect(
() => {
StudioApiService.getProctoredExamSettingsData(props.courseId)
StudioApiService.getProctoredExamSettingsData(courseId)
.then(
response => {
const proctoredExamSettings = response.data.proctored_exam_settings;
@@ -550,11 +550,11 @@ function ExamSettings(props) {
);
}
ExamSettings.propTypes = {
intl: intlShape.isRequired,
ProctoredExamSettings.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
ExamSettings.defaultProps = {};
ProctoredExamSettings.defaultProps = {};
export default injectIntl(ExamSettings);
export default injectIntl(ProctoredExamSettings);

View File

@@ -410,7 +410,7 @@ describe('ProctoredExamSettings connection states tests', () => {
auth.getAuthenticatedHttpClient = jest.fn(() => ({
get: jest.fn(() => new Promise(() => {})),
}));
render(<IntlProvider local="en"><IntlProctoredExamSettings {...defaultProps} /></IntlProvider>);
render(<IntlProvider locale="en"><IntlProctoredExamSettings {...defaultProps} /></IntlProvider>);
const spinner = screen.getByTestId('spinnerContainer');
expect(spinner.textContent).toEqual('Loading...');
});

View File

@@ -1,6 +1,7 @@
import 'babel-polyfill';
import axios from 'axios';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import { configure } from '@testing-library/react';
configure({ testIdAttribute: 'data-test-id' });

13
src/store.js Normal file
View File

@@ -0,0 +1,13 @@
import { configureStore } from '@reduxjs/toolkit';
import { reducer as modelsReducer } from './generic/model-store';
import { reducer as courseDetailReducer } from './data/slice';
export default function initializeStore() {
return configureStore({
reducer: {
courseDetail: courseDetailReducer,
models: modelsReducer,
},
});
}

View File

@@ -1,7 +1,7 @@
/* eslint-disable jsx-a11y/control-has-associated-label */
/* eslint-disable jsx-a11y/anchor-has-content */
import PropTypes from 'prop-types';
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext } from 'react';
import Responsive from 'react-responsive';
import { AppContext } from '@edx/frontend-platform/react';
import { ensureConfig } from '@edx/frontend-platform';
@@ -15,7 +15,6 @@ import MobileHeader from './MobileHeader';
import messages from './Header.messages';
import StudioLogoPNG from './assets/studio-logo.png';
import LmsApiService from '../data/services/LmsApiService';
ensureConfig([
'STUDIO_BASE_URL',
@@ -23,30 +22,10 @@ ensureConfig([
'LOGIN_URL',
], 'Header component');
function Header({ courseId, intl }) {
function Header({
courseId, courseNumber, courseOrg, courseTitle, intl,
}) {
const { authenticatedUser, config } = useContext(AppContext);
const [courseNumber, setCourseNumber] = useState('');
const [courseOrg, setCourseOrg] = useState('');
const [courseTitle, setCourseTitle] = useState('');
useEffect(
() => {
LmsApiService.getCourseDetailsData(courseId)
.then(
response => {
setCourseNumber(response.data.number);
setCourseOrg(response.data.org);
setCourseTitle(response.data.name);
},
).catch(
() => {
setCourseNumber('');
setCourseOrg('');
setCourseTitle(courseId);
},
);
}, [],
);
const mainMenu = [
{
@@ -160,7 +139,15 @@ function Header({ courseId, intl }) {
Header.propTypes = {
courseId: PropTypes.string.isRequired,
courseNumber: PropTypes.string,
courseOrg: PropTypes.string,
courseTitle: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
Header.defaultProps = {
courseNumber: null,
courseOrg: null,
};
export default injectIntl(Header);

View File

@@ -3,9 +3,7 @@ import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import { Context as ResponsiveContext } from 'react-responsive';
import * as auth from '@edx/frontend-platform/auth';
import {
act,
cleanup,
render,
screen,
@@ -14,13 +12,7 @@ import {
import Header from './Header';
describe('<Header />', () => {
function mockRequest(getFunction) {
auth.getAuthenticatedHttpClient = jest.fn(() => ({
get: getFunction,
}));
}
function createComponent(screenWidth) {
function createComponent(screenWidth, component) {
return (
<ResponsiveContext.Provider value={{ width: screenWidth }}>
<IntlProvider locale="en" messages={{}}>
@@ -39,60 +31,68 @@ describe('<Header />', () => {
},
}}
>
<Header courseId="course-v1:edX+DemoX+Demo_Course" />
{component}
</AppContext.Provider>
</IntlProvider>
</ResponsiveContext.Provider>
);
}
const successfulCall = async () => ({
data: {
number: 'DemoX',
org: 'edX',
name: 'Demonstration Course',
},
});
const errorObject = {
customAttributes: {
httpErrorStatus: 404,
},
};
const badCall = async () => { throw errorObject; };
it('renders desktop header correctly with API call', async () => {
mockRequest(successfulCall);
const component = createComponent(1280);
const component = createComponent(1280, (
<Header
courseId="course-v1:edX+DemoX+Demo_Course"
courseNumber="DemoX"
courseOrg="edX"
courseTitle="Demonstration Course"
/>
));
await act(async () => render(component));
render(component);
expect(screen.getByTestId('course-org-number').textContent).toEqual(expect.stringContaining('edX DemoX'));
expect(screen.getByTestId('course-title').textContent).toEqual(expect.stringContaining('Demonstration Course'));
});
it('renders mobile header correctly with API call', async () => {
mockRequest(successfulCall);
const component = createComponent(500);
const component = createComponent(500, (
<Header
courseId="course-v1:edX+DemoX+Demo_Course"
courseNumber="DemoX"
courseOrg="edX"
courseTitle="Demonstration Course"
/>
));
await act(async () => render(component));
render(component);
expect(screen.getAllByTestId('course-org-number')[0].textContent).toEqual(expect.stringContaining('edX DemoX'));
expect(screen.getAllByTestId('course-title')[0].textContent).toEqual(expect.stringContaining('Demonstration Course'));
});
it('renders desktop header correctly with bad API call', async () => {
mockRequest(badCall);
const component = createComponent(1280);
const component = createComponent(1280, (
<Header
courseId="course-v1:edX+DemoX+Demo_Course"
courseNumber={null}
courseOrg={null}
courseTitle="course-v1:edX+DemoX+Demo_Course"
/>
));
await act(async () => render(component));
render(component);
expect(screen.getByTestId('course-title').textContent).toEqual(expect.stringContaining('course-v1:edX+DemoX+Demo_Course'));
});
it('renders mobile header correctly with bad API call', async () => {
mockRequest(badCall);
const component = createComponent(500);
const component = createComponent(500, (
<Header
courseId="course-v1:edX+DemoX+Demo_Course"
courseNumber={null}
courseOrg={null}
courseTitle="course-v1:edX+DemoX+Demo_Course"
/>
));
await act(async () => render(component));
render(component);
expect(screen.getAllByTestId('course-title')[0].textContent).toEqual(expect.stringContaining('course-v1:edX+DemoX+Demo_Course'));
});